test(integration): add e2e flows for flutter app and java backend features

This commit is contained in:
2026-05-17 02:18:29 +07:00
parent 558ef66a55
commit 4a0ae1d615
8 changed files with 3747 additions and 0 deletions
@@ -0,0 +1,143 @@
package com.walkguide.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.walkguide.dto.request.LoginRequest;
import com.walkguide.dto.request.RegisterRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Base class untuk semua Integration Test menggunakan Testcontainers.
* Spin up PostgreSQL nyata dalam Docker container, jalankan Flyway migration,
* lalu test real HTTP request via MockMvc terhadap Spring context penuh.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// =========================================================================
// SHARED POSTGRESQL TESTCONTAINER — satu instance untuk semua test class
// =========================================================================
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("walkguide_test")
.withUsername("testuser")
.withPassword("testpass");
/**
* Override spring datasource properties menggunakan URL dari Testcontainer.
* Menggantikan koneksi ke server kampus saat test berjalan.
*/
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
registry.add("firebase.enabled", () -> "false");
}
// =========================================================================
// AUTOWIRED FIELDS
// =========================================================================
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
// =========================================================================
// HELPER METHODS
// =========================================================================
/**
* Register user baru dan return access token.
* DTO RegisterRequest pakai @Data (Lombok) — setter tersedia saat runtime Maven,
* meskipun NetBeans IDE menampilkan false-positive error karena Lombok-NetBeans incompatibility.
*/
protected String registerAndGetToken(String email, String password, String role)
throws Exception {
RegisterRequest req = new RegisterRequest();
req.setEmail(email);
req.setPassword(password);
req.setDisplayName("Test " + role);
req.setRole(role);
MvcResult result = mockMvc.perform(
post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andReturn();
JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString());
return json.path("data").path("accessToken").asText();
}
/**
* Login dengan credentials dan return access token.
*/
protected String loginAndGetToken(String email, String password) throws Exception {
LoginRequest req = new LoginRequest();
req.setEmail(email);
req.setPassword(password);
MvcResult result = mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andReturn();
JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString());
return json.path("data").path("accessToken").asText();
}
/**
* Register user dan return seluruh AuthDataResponse sebagai JsonNode.
* Berguna ketika test butuh uniqueUserId, userId, role, dll.
*/
protected JsonNode registerAndGetFullResponse(String email, String password, String role)
throws Exception {
RegisterRequest req = new RegisterRequest();
req.setEmail(email);
req.setPassword(password);
req.setDisplayName("Test " + role);
req.setRole(role);
MvcResult result = mockMvc.perform(
post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andReturn();
JsonNode json = objectMapper.readTree(result.getResponse().getContentAsString());
return json.path("data");
}
/** Buat Authorization header dari token. */
protected String bearerToken(String token) {
return "Bearer " + token;
}
}
@@ -0,0 +1,294 @@
package com.walkguide.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.LoginRequest;
import com.walkguide.dto.request.RefreshTokenRequest;
import com.walkguide.dto.request.RegisterRequest;
import org.junit.jupiter.api.*;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration Test — Alur Auth (Register / Login / Refresh / Logout).
*
* Menggunakan Testcontainers PostgreSQL nyata + @SpringBootTest penuh.
* Tidak ada mocking — semua hit database container sungguhan.
*
* CATATAN IDE: Error "cannot find symbol setEmail/setPassword/dll" di NetBeans
* adalah FALSE POSITIVE — Lombok @Data tidak diproses oleh NetBeans LSP.
* Test ini berjalan normal dengan: mvn test
*/
@DisplayName("Integration Test — Auth Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AuthIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "integ_user@walkguide.test";
private static final String USER_PASS = "password123";
private static final String GUARDIAN_EMAIL = "integ_guardian@walkguide.test";
private static final String GUARDIAN_PASS = "guardian123";
// Shared state antar test (ordered)
private static String userAccessToken;
private static String userRefreshToken;
private static String guardianAccessToken;
// ==========================================================================
// 1. Register
// ==========================================================================
@Test
@Order(1)
@DisplayName("POST /register — ROLE_USER sukses dapat access + refresh token")
void register_roleUser_success() throws Exception {
RegisterRequest req = new RegisterRequest();
req.setEmail(USER_EMAIL);
req.setPassword(USER_PASS);
req.setDisplayName("Integration User");
req.setRole("USER");
String responseBody = mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Registrasi berhasil"))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.refreshToken").isNotEmpty())
.andExpect(jsonPath("$.data.role").value("ROLE_USER"))
.andReturn().getResponse().getContentAsString();
JsonNode data = objectMapper.readTree(responseBody).path("data");
userAccessToken = data.path("accessToken").asText();
userRefreshToken = data.path("refreshToken").asText();
assertThat(userAccessToken).isNotBlank();
assertThat(userRefreshToken).isNotBlank();
}
@Test
@Order(2)
@DisplayName("POST /register — ROLE_GUARDIAN sukses")
void register_roleGuardian_success() throws Exception {
RegisterRequest req = new RegisterRequest();
req.setEmail(GUARDIAN_EMAIL);
req.setPassword(GUARDIAN_PASS);
req.setDisplayName("Integration Guardian");
req.setRole("GUARDIAN");
String responseBody = mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.role").value("ROLE_GUARDIAN"))
.andReturn().getResponse().getContentAsString();
guardianAccessToken = objectMapper.readTree(responseBody)
.path("data").path("accessToken").asText();
assertThat(guardianAccessToken).isNotBlank();
}
@Test
@Order(3)
@DisplayName("POST /register — email duplikat harus return error (bukan 200)")
void register_duplicateEmail_returnsError() throws Exception {
RegisterRequest req = new RegisterRequest();
req.setEmail(USER_EMAIL); // sudah ada dari Order(1)
req.setPassword("anotherpass");
req.setDisplayName("Duplicate");
req.setRole("USER");
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
@Test
@Order(4)
@DisplayName("POST /register — body kosong harus return 400 validation error")
void register_emptyBody_returns400() throws Exception {
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
// ==========================================================================
// 2. Login
// ==========================================================================
@Test
@Order(5)
@DisplayName("POST /login — credentials valid dapat token baru")
void login_validCredentials_returnsNewToken() throws Exception {
LoginRequest req = new LoginRequest();
req.setEmail(USER_EMAIL);
req.setPassword(USER_PASS);
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Login berhasil"))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.role").value("ROLE_USER"));
}
@Test
@Order(6)
@DisplayName("POST /login — email tidak terdaftar harus return error")
void login_unknownEmail_returnsError() throws Exception {
LoginRequest req = new LoginRequest();
req.setEmail("tidakada@walkguide.test");
req.setPassword("irrelevant");
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
@Test
@Order(7)
@DisplayName("POST /login — password salah harus return error")
void login_wrongPassword_returnsError() throws Exception {
LoginRequest req = new LoginRequest();
req.setEmail(USER_EMAIL);
req.setPassword("wrongpassword");
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
// ==========================================================================
// 3. Refresh Token
// ==========================================================================
@Test
@Order(8)
@DisplayName("POST /refresh — refresh token valid dapat access token baru")
void refresh_validToken_returnsNewAccessToken() throws Exception {
assertThat(userRefreshToken).isNotBlank();
RefreshTokenRequest req = new RefreshTokenRequest();
req.setRefreshToken(userRefreshToken);
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Token diperbarui"))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty());
}
@Test
@Order(9)
@DisplayName("POST /refresh — token palsu harus return error")
void refresh_fakeToken_returnsError() throws Exception {
RefreshTokenRequest req = new RefreshTokenRequest();
req.setRefreshToken("totally-invalid-refresh-token-xyz");
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
// ==========================================================================
// 4. Logout
// ==========================================================================
@Test
@Order(10)
@DisplayName("POST /logout — dengan token valid harus return 200 logout berhasil")
void logout_withValidToken_returns200() throws Exception {
assertThat(userAccessToken).isNotBlank();
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", bearerToken(userAccessToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Logout berhasil"));
}
@Test
@Order(11)
@DisplayName("POST /logout — tanpa token harus return 403")
void logout_withoutToken_returns403() throws Exception {
mockMvc.perform(post("/api/v1/auth/logout"))
.andExpect(status().isForbidden());
}
// ==========================================================================
// 5. RBAC
// ==========================================================================
@Test
@Order(12)
@DisplayName("RBAC — USER token tidak bisa akses /api/v1/guardian/** → 403")
void rbac_userCannotAccessGuardianEndpoint() throws Exception {
// Login ulang karena token dari Order(1) sudah di-logout di Order(10)
String freshUserToken = loginAndGetToken(USER_EMAIL, USER_PASS);
mockMvc.perform(get("/api/v1/guardian/dashboard")
.header("Authorization", bearerToken(freshUserToken)))
.andExpect(status().isForbidden());
}
@Test
@Order(13)
@DisplayName("RBAC — GUARDIAN token tidak bisa akses /api/v1/user/** → 403")
void rbac_guardianCannotAccessUserEndpoint() throws Exception {
mockMvc.perform(get("/api/v1/user/profile")
.header("Authorization", bearerToken(guardianAccessToken)))
.andExpect(status().isForbidden());
}
@Test
@Order(14)
@DisplayName("RBAC — Request tanpa token ke protected endpoint → 403")
void rbac_noToken_protectedEndpoint_returns403() throws Exception {
mockMvc.perform(get("/api/v1/user/profile"))
.andExpect(status().isForbidden());
}
// ==========================================================================
// 6. Endpoint publik
// ==========================================================================
@Test
@Order(15)
@DisplayName("GET /ping — endpoint publik, tidak perlu auth → 200 pong")
void ping_publicEndpoint_returns200() throws Exception {
mockMvc.perform(get("/api/v1/auth/ping"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").value("pong"));
}
@Test
@Order(16)
@DisplayName("GET /swagger-ui.html — endpoint publik → 2xx atau redirect")
void swaggerUi_publicEndpoint_accessible() throws Exception {
mockMvc.perform(get("/swagger-ui.html"))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isBetween(200, 399));
}
}
@@ -0,0 +1,286 @@
package com.walkguide.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.InviteUserRequest;
import com.walkguide.dto.request.PairingResponseRequest;
import org.junit.jupiter.api.*;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration Test — Alur Pairing Guardian ↔ User.
*
* Menggunakan Testcontainers PostgreSQL nyata.
* State transitions yang di-test:
* Guardian invite User → PENDING → User accept → ACTIVE → unpair
* Guardian invite User → PENDING → User reject → REJECTED
*
* CATATAN IDE: Error "setUniqueUserId/setPairingId/setAccept" di NetBeans
* adalah FALSE POSITIVE karena Lombok @Data tidak diproses IDE.
* Berjalan normal dengan: mvn test
*/
@DisplayName("Integration Test — Pairing Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class PairingIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "pairing_user@walkguide.test";
private static final String USER_PASS = "userpass123";
private static final String GUARDIAN_EMAIL = "pairing_guardian@walkguide.test";
private static final String GUARDIAN_PASS = "guardianpass123";
private static String userToken;
private static String guardianToken;
private static String uniqueUserId;
private static Long pairingId;
// ==========================================================================
// Setup — register kedua user
// ==========================================================================
@Test
@Order(1)
@DisplayName("Setup — Register User dan Guardian, simpan token + uniqueUserId")
void setup_registerBothRoles() throws Exception {
JsonNode userData = registerAndGetFullResponse(USER_EMAIL, USER_PASS, "USER");
userToken = userData.path("accessToken").asText();
uniqueUserId = userData.path("uniqueUserId").asText();
assertThat(userToken).isNotBlank();
assertThat(uniqueUserId).hasSize(12);
JsonNode guardianData = registerAndGetFullResponse(GUARDIAN_EMAIL, GUARDIAN_PASS, "GUARDIAN");
guardianToken = guardianData.path("accessToken").asText();
assertThat(guardianToken).isNotBlank();
}
// ==========================================================================
// Invite
// ==========================================================================
@Test
@Order(2)
@DisplayName("POST /shared/pairing/invite — Guardian invite User dengan uniqueUserId valid → PENDING")
void invite_validUniqueUserId_returnsPending() throws Exception {
// InviteUserRequest: field = uniqueUserId (@Data → setter tersedia di runtime)
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId(uniqueUserId);
String responseBody = mockMvc.perform(post("/api/v1/shared/pairing/invite")
.header("Authorization", bearerToken(guardianToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Undangan dikirim ke user"))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andReturn().getResponse().getContentAsString();
JsonNode data = objectMapper.readTree(responseBody).path("data");
pairingId = data.path("pairingId").asLong();
assertThat(pairingId).isGreaterThan(0);
}
@Test
@Order(3)
@DisplayName("POST /shared/pairing/invite — uniqueUserId tidak ada → error")
void invite_unknownUniqueUserId_returnsError() throws Exception {
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("XXXXXXXXXXXX"); // 12 char tapi tidak ada di DB
mockMvc.perform(post("/api/v1/shared/pairing/invite")
.header("Authorization", bearerToken(guardianToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
@Test
@Order(4)
@DisplayName("POST /shared/pairing/invite — User (bukan Guardian) coba invite → error")
void invite_asUser_returnsError() throws Exception {
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("SOMEUSRXXXXX");
mockMvc.perform(post("/api/v1/shared/pairing/invite")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isGreaterThanOrEqualTo(400));
}
// ==========================================================================
// Status PENDING
// ==========================================================================
@Test
@Order(5)
@DisplayName("GET /shared/pairing/status — User lihat status PENDING")
void status_asUser_pending() throws Exception {
mockMvc.perform(get("/api/v1/shared/pairing/status")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Status pairing"))
.andExpect(jsonPath("$.data.status").value("PENDING"));
}
@Test
@Order(6)
@DisplayName("GET /shared/pairing/status — Guardian lihat status PENDING")
void status_asGuardian_pending() throws Exception {
mockMvc.perform(get("/api/v1/shared/pairing/status")
.header("Authorization", bearerToken(guardianToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PENDING"));
}
// ==========================================================================
// Accept → ACTIVE
// ==========================================================================
@Test
@Order(7)
@DisplayName("POST /shared/pairing/respond — User accept pairing → ACTIVE")
void respond_accept_returnsActive() throws Exception {
assertThat(pairingId).isGreaterThan(0);
// PairingResponseRequest: fields = pairingId, accept (@Data → setter tersedia)
PairingResponseRequest req = new PairingResponseRequest();
req.setPairingId(pairingId);
req.setAccept(true);
mockMvc.perform(post("/api/v1/shared/pairing/respond")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Pairing diterima"))
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
}
@Test
@Order(8)
@DisplayName("GET /shared/pairing/status — setelah accept, status ACTIVE")
void status_afterAccept_isActive() throws Exception {
mockMvc.perform(get("/api/v1/shared/pairing/status")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
}
@Test
@Order(9)
@DisplayName("GET /guardian/dashboard — setelah paired, Guardian bisa akses dashboard")
void guardianDashboard_afterPairing_returns200() throws Exception {
mockMvc.perform(get("/api/v1/guardian/dashboard")
.header("Authorization", bearerToken(guardianToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Dashboard Guardian"));
}
// ==========================================================================
// Unpair
// ==========================================================================
@Test
@Order(10)
@DisplayName("DELETE /shared/pairing/unpair — User unpair → 200 pairing diakhiri")
void unpair_asUser_returns200() throws Exception {
mockMvc.perform(delete("/api/v1/shared/pairing/unpair")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Pairing diakhiri"));
}
@Test
@Order(11)
@DisplayName("GET /shared/pairing/status — setelah unpair, tidak ada pairing ACTIVE")
void status_afterUnpair_isNotActive() throws Exception {
mockMvc.perform(get("/api/v1/shared/pairing/status")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(result -> {
String body = result.getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
String statusVal = json.path("data").path("status").asText("NONE");
assertThat(statusVal).isNotEqualTo("ACTIVE");
});
}
// ==========================================================================
// Skenario Reject
// ==========================================================================
@Test
@Order(12)
@DisplayName("Skenario Reject: Guardian invite baru → User tolak → REJECTED")
void scenario_reject_flow() throws Exception {
String rejectUserEmail = "reject_user@walkguide.test";
String rejectGuardianEmail = "reject_guardian@walkguide.test";
JsonNode rejectUserData = registerAndGetFullResponse(rejectUserEmail, "pass123", "USER");
String rejectUserToken = rejectUserData.path("accessToken").asText();
String rejectUniqueId = rejectUserData.path("uniqueUserId").asText();
JsonNode rejectGuardData = registerAndGetFullResponse(rejectGuardianEmail, "pass123", "GUARDIAN");
String rejectGuardToken = rejectGuardData.path("accessToken").asText();
// Guardian invite
InviteUserRequest inviteReq = new InviteUserRequest();
inviteReq.setUniqueUserId(rejectUniqueId);
String inviteResponse = mockMvc.perform(post("/api/v1/shared/pairing/invite")
.header("Authorization", bearerToken(rejectGuardToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(inviteReq)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
Long rejectPairingId = objectMapper.readTree(inviteResponse)
.path("data").path("pairingId").asLong();
// User tolak
PairingResponseRequest rejectReq = new PairingResponseRequest();
rejectReq.setPairingId(rejectPairingId);
rejectReq.setAccept(false);
mockMvc.perform(post("/api/v1/shared/pairing/respond")
.header("Authorization", bearerToken(rejectUserToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(rejectReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Pairing ditolak"))
.andExpect(jsonPath("$.data.status").value("REJECTED"));
}
// ==========================================================================
// Endpoint tanpa auth
// ==========================================================================
@Test
@Order(13)
@DisplayName("DELETE /shared/pairing/unpair — tanpa token → 403")
void unpair_withoutToken_returns403() throws Exception {
mockMvc.perform(delete("/api/v1/shared/pairing/unpair"))
.andExpect(status().isForbidden());
}
@Test
@Order(14)
@DisplayName("GET /shared/pairing/status — tanpa token → 403")
void status_withoutToken_returns403() throws Exception {
mockMvc.perform(get("/api/v1/shared/pairing/status"))
.andExpect(status().isForbidden());
}
}
@@ -0,0 +1,488 @@
package com.walkguide.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.*;
import org.junit.jupiter.api.*;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration Test — Fitur Inti User: Location, Obstacle, SOS, Notification, WalkGuide.
*
* Menggunakan Testcontainers PostgreSQL nyata + full Spring context.
*
* FIELD NAMES SESUAI DTO AKTUAL:
* - LocationUpdateRequest : lat, lng (bukan latitude/longitude)
* - ObstacleLogRequest : label, confidence (Double), direction, estimatedDist (bukan distance)
* - SosRequest : triggerType, lat, lng
* - SendNotificationRequest: notifType, content (bukan title/message)
* - UserSettingsUpdateRequest: ttsLanguage, ttsSpeed (Double, bukan float)
*
* CATATAN IDE: Error field/method di NetBeans adalah FALSE POSITIVE (Lombok-NetBeans).
* Berjalan normal dengan: mvn test
*/
@DisplayName("Integration Test — User Core Features (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserFeatureIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "feature_user@walkguide.test";
private static final String USER_PASS = "userpass123";
private static final String GUARDIAN_EMAIL = "feature_guardian@walkguide.test";
private static final String GUARDIAN_PASS = "guardpass123";
private static String userToken;
private static String guardianToken;
private static String uniqueUserId;
private static Long sosEventId;
private static Long notificationId;
// ==========================================================================
// Setup — register & pair
// ==========================================================================
@Test
@Order(1)
@DisplayName("Setup — Register & Pair Guardian ↔ User")
void setup_registerAndPair() throws Exception {
JsonNode userData = registerAndGetFullResponse(USER_EMAIL, USER_PASS, "USER");
userToken = userData.path("accessToken").asText();
uniqueUserId = userData.path("uniqueUserId").asText();
JsonNode guardianData = registerAndGetFullResponse(GUARDIAN_EMAIL, GUARDIAN_PASS, "GUARDIAN");
guardianToken = guardianData.path("accessToken").asText();
// Guardian invite User
InviteUserRequest inviteReq = new InviteUserRequest();
inviteReq.setUniqueUserId(uniqueUserId);
String inviteResponse = mockMvc.perform(post("/api/v1/shared/pairing/invite")
.header("Authorization", bearerToken(guardianToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(inviteReq)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
Long pairingId = objectMapper.readTree(inviteResponse)
.path("data").path("pairingId").asLong();
// User accept
// PairingResponseRequest: pairingId, accept
PairingResponseRequest acceptReq = new PairingResponseRequest();
acceptReq.setPairingId(pairingId);
acceptReq.setAccept(true);
mockMvc.perform(post("/api/v1/shared/pairing/respond")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(acceptReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
}
// ==========================================================================
// Location
// PENTING: LocationUpdateRequest field = lat, lng (BUKAN latitude/longitude)
// ==========================================================================
@Test
@Order(2)
@DisplayName("POST /user/location — update lokasi GPS berhasil")
void updateLocation_validCoords_returns200() throws Exception {
// LocationUpdateRequest fields: lat, lng, accuracy, speed, heading
LocationUpdateRequest req = new LocationUpdateRequest();
req.setLat(-7.2575);
req.setLng(112.7521);
mockMvc.perform(post("/api/v1/user/location")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Lokasi diperbarui"));
}
@Test
@Order(3)
@DisplayName("POST /user/location — body kosong tetap valid (semua field optional)")
void updateLocation_emptyBody_returns200OrError() throws Exception {
// LocationUpdateRequest tidak ada @NotNull — bisa kirim kosong
mockMvc.perform(post("/api/v1/user/location")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(result ->
assertThat(result.getResponse().getStatus())
.isBetween(200, 599)); // terima apapun, asal tidak crash 5xx karena NPE
}
@Test
@Order(4)
@DisplayName("GET /guardian/user-location — Guardian dapat melihat lokasi terakhir User")
void guardianGetUserLocation_afterUpdate_returnsLocation() throws Exception {
mockMvc.perform(get("/api/v1/guardian/user-location")
.header("Authorization", bearerToken(guardianToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Lokasi terakhir user"));
}
@Test
@Order(5)
@DisplayName("GET /guardian/location-history — Guardian dapat riwayat lokasi terpaginasi")
void guardianGetLocationHistory_returns200WithPage() throws Exception {
mockMvc.perform(get("/api/v1/guardian/location-history")
.header("Authorization", bearerToken(guardianToken))
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Riwayat lokasi"));
}
// ==========================================================================
// Obstacle
// PENTING: ObstacleLogRequest fields = label, confidence (Double), direction,
// estimatedDist (String) — TIDAK ADA field "distance"
// ==========================================================================
@Test
@Order(6)
@DisplayName("POST /user/obstacle — log obstacle berhasil")
void logObstacle_validRequest_returns200() throws Exception {
// ObstacleLogRequest fields: label, confidence (Double), direction, estimatedDist, lat, lng
ObstacleLogRequest req = new ObstacleLogRequest();
req.setLabel("person");
req.setConfidence(0.87); // Double, bukan float
req.setDirection("depan");
req.setEstimatedDist("1.5m"); // String, bukan float
req.setLat(-7.2575);
req.setLng(112.7521);
mockMvc.perform(post("/api/v1/user/obstacle")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Obstacle dicatat"));
}
@Test
@Order(7)
@DisplayName("GET /guardian/obstacle-logs — Guardian lihat obstacle logs User")
void guardianGetObstacleLogs_returns200() throws Exception {
mockMvc.perform(get("/api/v1/guardian/obstacle-logs")
.header("Authorization", bearerToken(guardianToken))
.param("page", "0").param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Log obstacle user"));
}
// ==========================================================================
// WalkGuide Start / Stop
// ==========================================================================
@Test
@Order(8)
@DisplayName("POST /user/walkguide/start — log WALKGUIDE_START berhasil")
void walkGuideStart_returns200() throws Exception {
mockMvc.perform(post("/api/v1/user/walkguide/start")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("WalkGuide dimulai"));
}
@Test
@Order(9)
@DisplayName("POST /user/walkguide/stop — log WALKGUIDE_STOP berhasil")
void walkGuideStop_returns200() throws Exception {
mockMvc.perform(post("/api/v1/user/walkguide/stop")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("WalkGuide dihentikan"));
}
@Test
@Order(10)
@DisplayName("GET /user/activity-logs — User lihat log aktivitas terpaginasi")
void userActivityLogs_returns200WithEntries() throws Exception {
mockMvc.perform(get("/api/v1/user/activity-logs")
.header("Authorization", bearerToken(userToken))
.param("page", "0").param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Log aktivitas"))
.andExpect(result -> {
String body = result.getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
int totalElements = json.path("data").path("totalElements").asInt(0);
assertThat(totalElements).isGreaterThanOrEqualTo(2); // min: start + stop
});
}
@Test
@Order(11)
@DisplayName("GET /guardian/activity-logs — Guardian lihat log aktivitas User")
void guardianActivityLogs_returns200() throws Exception {
mockMvc.perform(get("/api/v1/guardian/activity-logs")
.header("Authorization", bearerToken(guardianToken))
.param("page", "0").param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Log aktivitas user"));
}
// ==========================================================================
// SOS
// PENTING: SosRequest fields = triggerType, lat, lng
// ==========================================================================
@Test
@Order(12)
@DisplayName("POST /user/sos — User kirim SOS → 200, status TRIGGERED")
void triggerSos_returns200WithTriggeredStatus() throws Exception {
// SosRequest fields: triggerType, lat, lng
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
req.setLat(-7.2575);
req.setLng(112.7521);
String responseBody = mockMvc.perform(post("/api/v1/user/sos")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("SOS dikirim! Guardian sudah diberitahu."))
.andReturn().getResponse().getContentAsString();
JsonNode data = objectMapper.readTree(responseBody).path("data");
sosEventId = data.path("id").asLong();
String sosStatus = data.path("status").asText();
assertThat(sosEventId).isGreaterThan(0);
assertThat(sosStatus).isEqualTo("TRIGGERED");
}
@Test
@Order(13)
@DisplayName("GET /guardian/sos-events — Guardian lihat SOS events User")
void guardianGetSosEvents_returns200() throws Exception {
mockMvc.perform(get("/api/v1/guardian/sos-events")
.header("Authorization", bearerToken(guardianToken))
.param("page", "0").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("SOS events"))
.andExpect(result -> {
String body = result.getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
int total = json.path("data").path("totalElements").asInt(0);
assertThat(total).isGreaterThanOrEqualTo(1);
});
}
@Test
@Order(14)
@DisplayName("PUT /guardian/sos/{id}/acknowledge — Guardian akui SOS → ACKNOWLEDGED")
void guardianAcknowledgeSos_returns200WithAcknowledgedStatus() throws Exception {
assertThat(sosEventId).isGreaterThan(0);
mockMvc.perform(put("/api/v1/guardian/sos/" + sosEventId + "/acknowledge")
.header("Authorization", bearerToken(guardianToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("SOS diakui"))
.andExpect(jsonPath("$.data.status").value("ACKNOWLEDGED"));
}
@Test
@Order(15)
@DisplayName("POST /user/sos — tanpa token → 403")
void triggerSos_withoutToken_returns403() throws Exception {
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
mockMvc.perform(post("/api/v1/user/sos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isForbidden());
}
// ==========================================================================
// Notifications
// PENTING: SendNotificationRequest fields = notifType, content (BUKAN title/message)
// ==========================================================================
@Test
@Order(16)
@DisplayName("POST /guardian/notifications/send — Guardian kirim notifikasi ke User")
void sendNotification_returns200WithNotifResponse() throws Exception {
// SendNotificationRequest fields: notifType, content, voiceNoteUrl, voiceNoteDuration
SendNotificationRequest req = new SendNotificationRequest();
req.setNotifType("TEXT");
req.setContent("Hati-hati! Ada kendaraan di depanmu");
String responseBody = mockMvc.perform(post("/api/v1/guardian/notifications/send")
.header("Authorization", bearerToken(guardianToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi terkirim"))
.andReturn().getResponse().getContentAsString();
notificationId = objectMapper.readTree(responseBody)
.path("data").path("id").asLong();
assertThat(notificationId).isGreaterThan(0);
}
@Test
@Order(17)
@DisplayName("GET /user/notifications — User lihat notifikasi yang diterima")
void getNotifications_returns200WithItems() throws Exception {
mockMvc.perform(get("/api/v1/user/notifications")
.header("Authorization", bearerToken(userToken))
.param("page", "0").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi"))
.andExpect(result -> {
String body = result.getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
int total = json.path("data").path("totalElements").asInt(0);
assertThat(total).isGreaterThanOrEqualTo(1);
});
}
@Test
@Order(18)
@DisplayName("GET /user/notifications/unread-count — count notifikasi belum dibaca ≥ 1")
void getUnreadCount_afterSend_isAtLeastOne() throws Exception {
mockMvc.perform(get("/api/v1/user/notifications/unread-count")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Jumlah notifikasi belum dibaca"))
.andExpect(result -> {
String body = result.getResponse().getContentAsString();
int count = objectMapper.readTree(body).path("data").asInt(0);
assertThat(count).isGreaterThanOrEqualTo(1);
});
}
@Test
@Order(19)
@DisplayName("PUT /user/notifications/{id}/read — mark satu notifikasi sebagai dibaca")
void markOneRead_returns200() throws Exception {
assertThat(notificationId).isGreaterThan(0);
mockMvc.perform(put("/api/v1/user/notifications/" + notificationId + "/read")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi ditandai sudah dibaca"));
}
@Test
@Order(20)
@DisplayName("PUT /user/notifications/mark-all-read — mark semua sebagai dibaca")
void markAllRead_returns200() throws Exception {
mockMvc.perform(put("/api/v1/user/notifications/mark-all-read")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Semua notifikasi ditandai sudah dibaca"));
}
@Test
@Order(21)
@DisplayName("GET /user/notifications/unread-count — setelah mark-all, count = 0")
void getUnreadCount_afterMarkAll_isZero() throws Exception {
mockMvc.perform(get("/api/v1/user/notifications/unread-count")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value(0));
}
// ==========================================================================
// User Profile & Settings
// PENTING: UserSettingsUpdateRequest.ttsSpeed = Double (pakai 1.2, bukan 1.2f)
// ==========================================================================
@Test
@Order(22)
@DisplayName("GET /user/profile — User lihat profil diri sendiri")
void getProfile_returns200WithEmail() throws Exception {
mockMvc.perform(get("/api/v1/user/profile")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.email").value(USER_EMAIL))
.andExpect(jsonPath("$.data.uniqueUserId").isNotEmpty());
}
@Test
@Order(23)
@DisplayName("GET /user/settings — User lihat settings dirinya")
void getSettings_returns200() throws Exception {
mockMvc.perform(get("/api/v1/user/settings")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Settings user"));
}
@Test
@Order(24)
@DisplayName("PUT /user/settings — User update TTS language dan speed")
void updateSettings_returns200WithUpdatedData() throws Exception {
// UserSettingsUpdateRequest fields: ttsLanguage, ttsPitch, ttsSpeed (Double), warnNoGuardian, hapticEnabled
UserSettingsUpdateRequest req = new UserSettingsUpdateRequest();
req.setTtsLanguage("id");
req.setTtsSpeed(1.2); // Double, bukan float — tidak pakai suffix 'f'
mockMvc.perform(put("/api/v1/user/settings")
.header("Authorization", bearerToken(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Settings diperbarui"));
}
// ==========================================================================
// RBAC guard pada feature endpoints
// ==========================================================================
@Test
@Order(25)
@DisplayName("POST /user/location — Guardian token coba akses endpoint User → 403")
void userEndpoint_withGuardianToken_returns403() throws Exception {
LocationUpdateRequest req = new LocationUpdateRequest();
req.setLat(-7.2575);
req.setLng(112.7521);
mockMvc.perform(post("/api/v1/user/location")
.header("Authorization", bearerToken(guardianToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isForbidden());
}
@Test
@Order(26)
@DisplayName("GET /guardian/dashboard — User token coba akses endpoint Guardian → 403")
void guardianEndpoint_withUserToken_returns403() throws Exception {
mockMvc.perform(get("/api/v1/guardian/dashboard")
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isForbidden());
}
}