test(integration): add e2e flows for flutter app and java backend features
This commit is contained in:
parent
558ef66a55
commit
4a0ae1d615
@ -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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,383 @@
|
||||
// integration_test/flow_1_login_dashboard_logout_test.dart
|
||||
//
|
||||
// E2E Flow 1: Login → Dashboard → Logout
|
||||
//
|
||||
// Alur yang diuji:
|
||||
// 1. User membuka app → tampil halaman Login
|
||||
// 2. User mengisi email + password yang valid → tap Login
|
||||
// 3. App berpindah ke Dashboard (HomeScreen)
|
||||
// 4. Dashboard menampilkan nama user & status pairing
|
||||
// 5. User tap Logout → kembali ke halaman Login
|
||||
//
|
||||
// Jalankan:
|
||||
// flutter test integration_test/flow_1_login_dashboard_logout_test.dart
|
||||
// atau dengan driver:
|
||||
// flutter drive \
|
||||
// --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/flow_1_login_dashboard_logout_test.dart
|
||||
//
|
||||
// Catatan:
|
||||
// Test ini menggunakan StubApp yang self-contained (tanpa koneksi server
|
||||
// kampus) sehingga bisa dijalankan di CI maupun device fisik tanpa
|
||||
// internet. Untuk E2E full terhadap server live, ganti _StubApp dengan
|
||||
// import 'package:walkguide_app/main.dart' as app; dan gunakan Mockito
|
||||
// untuk mock ApiClient + SecureStorage.
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
// ─── Fake Credentials ────────────────────────────────────────────────────────
|
||||
|
||||
const _kValidEmail = 'user@walkguide.test';
|
||||
const _kValidPassword = 'Password123!';
|
||||
const _kDisplayName = 'Budi Tunanetra';
|
||||
|
||||
// ─── Stub State ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _AuthState extends ChangeNotifier {
|
||||
String? _accessToken;
|
||||
String? _displayName;
|
||||
|
||||
bool get isLoggedIn => _accessToken != null;
|
||||
String get displayName => _displayName ?? '';
|
||||
|
||||
/// Simulasi login API call (200 ms delay).
|
||||
Future<bool> login(String email, String password) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
if (email == _kValidEmail && password == _kValidPassword) {
|
||||
_accessToken = 'fake-jwt-token';
|
||||
_displayName = _kDisplayName;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Simulasi logout.
|
||||
void logout() {
|
||||
_accessToken = null;
|
||||
_displayName = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stub App ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StubApp extends StatefulWidget {
|
||||
const _StubApp();
|
||||
|
||||
@override
|
||||
State<_StubApp> createState() => _StubAppState();
|
||||
}
|
||||
|
||||
class _StubAppState extends State<_StubApp> {
|
||||
final _auth = _AuthState();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_auth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'WalkGuide Test',
|
||||
home: ListenableBuilder(
|
||||
listenable: _auth,
|
||||
builder: (context, _) {
|
||||
if (_auth.isLoggedIn) {
|
||||
return _DashboardScreen(auth: _auth);
|
||||
}
|
||||
return _LoginScreen(auth: _auth);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login Screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _LoginScreen extends StatefulWidget {
|
||||
final _AuthState auth;
|
||||
const _LoginScreen({required this.auth});
|
||||
|
||||
@override
|
||||
State<_LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<_LoginScreen> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final ok = await widget.auth.login(
|
||||
_emailCtrl.text.trim(),
|
||||
_passwordCtrl.text.trim(),
|
||||
);
|
||||
if (!ok && mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Email atau password salah';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('WalkGuide — Login')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
key: const Key('emailField'),
|
||||
controller: _emailCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('passwordField'),
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_error != null)
|
||||
Text(
|
||||
_error!,
|
||||
key: const Key('loginError'),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(key: Key('loginLoading'))
|
||||
else
|
||||
ElevatedButton(
|
||||
key: const Key('loginButton'),
|
||||
onPressed: _submit,
|
||||
child: const Text('Login'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardScreen extends StatelessWidget {
|
||||
final _AuthState auth;
|
||||
const _DashboardScreen({required this.auth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dashboard'),
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const Key('logoutButton'),
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Logout',
|
||||
onPressed: auth.logout,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat datang, ${auth.displayName}',
|
||||
key: const Key('welcomeText'),
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Status Pairing: Belum terhubung',
|
||||
key: Key('pairingStatus'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Flow 1 — Login → Dashboard → Logout', () {
|
||||
testWidgets(
|
||||
'1.1 Halaman Login tampil saat app pertama dibuka',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('WalkGuide — Login'), findsOneWidget,
|
||||
reason: 'AppBar harus menampilkan judul Login');
|
||||
expect(find.byKey(const Key('emailField')), findsOneWidget);
|
||||
expect(find.byKey(const Key('passwordField')), findsOneWidget);
|
||||
expect(find.byKey(const Key('loginButton')), findsOneWidget);
|
||||
|
||||
print('[PASS] 1.1 Login screen tampil dengan semua field');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'1.2 Login dengan credentials yang salah → tampil pesan error',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), 'wrong@email.com');
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), 'wrongpass');
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('loginError')), findsOneWidget,
|
||||
reason: 'Error message harus muncul saat credentials salah');
|
||||
expect(find.text('WalkGuide — Login'), findsOneWidget,
|
||||
reason: 'Harus tetap di halaman Login');
|
||||
|
||||
print('[PASS] 1.2 Error message tampil untuk credentials salah');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'1.3 Login berhasil dengan credentials valid → pindah ke Dashboard',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), _kValidEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kValidPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dashboard'), findsOneWidget,
|
||||
reason: 'Setelah login berhasil harus tampil halaman Dashboard');
|
||||
expect(
|
||||
find.byKey(const Key('welcomeText')),
|
||||
findsOneWidget,
|
||||
reason: 'Pesan selamat datang harus muncul',
|
||||
);
|
||||
expect(
|
||||
find.textContaining(_kDisplayName),
|
||||
findsOneWidget,
|
||||
reason: 'Nama user harus ditampilkan di Dashboard',
|
||||
);
|
||||
|
||||
print('[PASS] 1.3 Login berhasil → Dashboard tampil dengan nama user');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'1.4 Dashboard menampilkan status pairing',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Login
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), _kValidEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kValidPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('pairingStatus')), findsOneWidget,
|
||||
reason: 'Status pairing harus tampil di Dashboard');
|
||||
|
||||
print('[PASS] 1.4 Dashboard menampilkan status pairing');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'1.5 Logout → kembali ke halaman Login',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Login
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), _kValidEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kValidPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
|
||||
// Logout
|
||||
await tester.tap(find.byKey(const Key('logoutButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('WalkGuide — Login'), findsOneWidget,
|
||||
reason: 'Setelah logout harus kembali ke halaman Login');
|
||||
expect(find.byKey(const Key('emailField')), findsOneWidget,
|
||||
reason: 'Form login harus tampil kembali');
|
||||
|
||||
print('[PASS] 1.5 Logout → kembali ke Login');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'1.6 Full round-trip: Login → Dashboard → Logout → Login kembali',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Login pertama
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), _kValidEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kValidPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
|
||||
// Logout
|
||||
await tester.tap(find.byKey(const Key('logoutButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('WalkGuide — Login'), findsOneWidget);
|
||||
|
||||
// Login lagi
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('emailField')), _kValidEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kValidPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dashboard'), findsOneWidget,
|
||||
reason: 'User harus bisa login kembali setelah logout');
|
||||
|
||||
print('[PASS] 1.6 Full round-trip Login → Logout → Login berhasil');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,580 @@
|
||||
// integration_test/flow_2_walkguide_start_stop_sos_test.dart
|
||||
//
|
||||
// E2E Flow 2: Login → WalkGuide → Start → Stop → SOS
|
||||
//
|
||||
// Alur yang diuji:
|
||||
// 1. Login berhasil → Dashboard
|
||||
// 2. Tap tombol WalkGuide → layar WalkGuide terbuka
|
||||
// 3. Tap "Mulai WalkGuide" → status berubah menjadi AKTIF
|
||||
// 4. Banner obstacle terdeteksi muncul
|
||||
// 5. Tap "Berhenti WalkGuide" → status kembali TIDAK AKTIF
|
||||
// 6. Tap tombol SOS → konfirmasi dialog muncul
|
||||
// 7. Konfirmasi SOS → status SOS TERKIRIM tampil
|
||||
//
|
||||
// Jalankan:
|
||||
// flutter test integration_test/flow_2_walkguide_start_stop_sos_test.dart
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
// ─── Fake Credentials ────────────────────────────────────────────────────────
|
||||
|
||||
const _kEmail = 'user@walkguide.test';
|
||||
const _kPassword = 'Password123!';
|
||||
|
||||
// ─── Stub State ──────────────────────────────────────────────────────────────
|
||||
|
||||
enum _WalkGuideStatus { idle, active }
|
||||
|
||||
enum _SosStatus { none, triggered }
|
||||
|
||||
class _AppState extends ChangeNotifier {
|
||||
bool _loggedIn = false;
|
||||
String _currentScreen = 'login'; // login | dashboard | walkguide | sos
|
||||
_WalkGuideStatus _walkGuideStatus = _WalkGuideStatus.idle;
|
||||
_SosStatus _sosStatus = _SosStatus.none;
|
||||
List<String> _detectedObstacles = [];
|
||||
|
||||
bool get loggedIn => _loggedIn;
|
||||
String get currentScreen => _currentScreen;
|
||||
_WalkGuideStatus get walkGuideStatus => _walkGuideStatus;
|
||||
_SosStatus get sosStatus => _sosStatus;
|
||||
List<String> get detectedObstacles => _detectedObstacles;
|
||||
|
||||
Future<bool> login(String email, String password) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
if (email == _kEmail && password == _kPassword) {
|
||||
_loggedIn = true;
|
||||
_currentScreen = 'dashboard';
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void openWalkGuide() {
|
||||
_currentScreen = 'walkguide';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> startWalkGuide() async {
|
||||
_walkGuideStatus = _WalkGuideStatus.active;
|
||||
notifyListeners();
|
||||
// Simulasi obstacle terdeteksi setelah 300ms
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void stopWalkGuide() {
|
||||
_walkGuideStatus = _WalkGuideStatus.idle;
|
||||
_detectedObstacles = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void openSos() {
|
||||
_currentScreen = 'sos';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendSos() async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
_sosStatus = _SosStatus.triggered;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void goBack() {
|
||||
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {
|
||||
_currentScreen = 'dashboard';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stub App ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StubApp extends StatefulWidget {
|
||||
const _StubApp();
|
||||
|
||||
@override
|
||||
State<_StubApp> createState() => _StubAppState();
|
||||
}
|
||||
|
||||
class _StubAppState extends State<_StubApp> {
|
||||
final _state = _AppState();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'WalkGuide Test',
|
||||
home: ListenableBuilder(
|
||||
listenable: _state,
|
||||
builder: (context, _) {
|
||||
switch (_state.currentScreen) {
|
||||
case 'walkguide':
|
||||
return _WalkGuideScreen(state: _state);
|
||||
case 'sos':
|
||||
return _SosScreen(state: _state);
|
||||
case 'dashboard':
|
||||
return _DashboardScreen(state: _state);
|
||||
default:
|
||||
return _LoginScreen(state: _state);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login Screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _LoginScreen extends StatefulWidget {
|
||||
final _AppState state;
|
||||
const _LoginScreen({required this.state});
|
||||
|
||||
@override
|
||||
State<_LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<_LoginScreen> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
final ok = await widget.state
|
||||
.login(_emailCtrl.text.trim(), _passwordCtrl.text.trim());
|
||||
if (!ok && mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Credentials tidak valid';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Login')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(children: [
|
||||
TextField(
|
||||
key: const Key('emailField'),
|
||||
controller: _emailCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('passwordField'),
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_error != null)
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator()
|
||||
else
|
||||
ElevatedButton(
|
||||
key: const Key('loginButton'),
|
||||
onPressed: _submit,
|
||||
child: const Text('Login'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardScreen extends StatelessWidget {
|
||||
final _AppState state;
|
||||
const _DashboardScreen({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dashboard')),
|
||||
body: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ElevatedButton(
|
||||
key: const Key('openWalkGuideButton'),
|
||||
onPressed: state.openWalkGuide,
|
||||
child: const Text('Buka WalkGuide'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
key: const Key('openSosButton'),
|
||||
onPressed: state.openSos,
|
||||
child: const Text('SOS'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WalkGuide Screen ─────────────────────────────────────────────────────────
|
||||
|
||||
class _WalkGuideScreen extends StatelessWidget {
|
||||
final _AppState state;
|
||||
const _WalkGuideScreen({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = state.walkGuideStatus == _WalkGuideStatus.active;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('WalkGuide'),
|
||||
leading: IconButton(
|
||||
key: const Key('backButton'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: state.goBack,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
// Status badge
|
||||
Container(
|
||||
key: const Key('walkGuideStatusBadge'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.green : Colors.grey,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isActive ? 'WalkGuide AKTIF' : 'WalkGuide TIDAK AKTIF',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Obstacle list
|
||||
if (state.detectedObstacles.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Obstacle Terdeteksi:',
|
||||
key: Key('obstacleHeader'),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...state.detectedObstacles.map((obs) => Text(
|
||||
obs,
|
||||
key: Key('obstacle_$obs'),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
||||
// Start / Stop button
|
||||
if (!isActive)
|
||||
ElevatedButton(
|
||||
key: const Key('startWalkGuideButton'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||
onPressed: state.startWalkGuide,
|
||||
child: const Text('Mulai WalkGuide'),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
key: const Key('stopWalkGuideButton'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: state.stopWalkGuide,
|
||||
child: const Text('Berhenti WalkGuide'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// SOS button
|
||||
OutlinedButton.icon(
|
||||
key: const Key('sosShortcutButton'),
|
||||
icon: const Icon(Icons.warning_amber, color: Colors.red),
|
||||
label: const Text('SOS', style: TextStyle(color: Colors.red)),
|
||||
onPressed: state.openSos,
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SOS Screen ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _SosScreen extends StatelessWidget {
|
||||
final _AppState state;
|
||||
const _SosScreen({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sent = state.sosStatus == _SosStatus.triggered;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('SOS Darurat'),
|
||||
leading: IconButton(
|
||||
key: const Key('sosBackButton'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: state.goBack,
|
||||
),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (sent)
|
||||
const Text(
|
||||
'SOS TERKIRIM',
|
||||
key: Key('sosSentLabel'),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const Text(
|
||||
'Tekan tombol SOS untuk mengirim bantuan darurat',
|
||||
key: Key('sosInstruction'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
key: const Key('sendSosButton'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
|
||||
),
|
||||
onPressed: () async {
|
||||
// Tampilkan dialog konfirmasi
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Konfirmasi SOS'),
|
||||
content: const Text('Kirim sinyal SOS ke Guardian?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const Key('cancelSosButton'),
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('confirmSosButton'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Ya, Kirim SOS'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await state.sendSos();
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'KIRIM SOS',
|
||||
style: TextStyle(fontSize: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Flow 2 — Login → WalkGuide → Start → Stop → SOS', () {
|
||||
/// Helper: login dan buka WalkGuide screen
|
||||
Future<void> loginAndOpenWalkGuide(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(const Key('emailField')), _kEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('openWalkGuideButton')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'2.1 WalkGuide screen tampil setelah login dan tap tombol WalkGuide',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
|
||||
expect(find.text('WalkGuide'), findsOneWidget,
|
||||
reason: 'Harus masuk ke WalkGuide screen');
|
||||
expect(find.byKey(const Key('walkGuideStatusBadge')), findsOneWidget);
|
||||
expect(find.text('WalkGuide TIDAK AKTIF'), findsOneWidget,
|
||||
reason: 'Status awal harus TIDAK AKTIF');
|
||||
|
||||
print(
|
||||
'[PASS] 2.1 WalkGuide screen tampil dengan status awal TIDAK AKTIF');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.2 Tap "Mulai WalkGuide" → status berubah menjadi AKTIF',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
|
||||
expect(find.byKey(const Key('startWalkGuideButton')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('WalkGuide AKTIF'), findsOneWidget,
|
||||
reason: 'Status harus AKTIF setelah tombol ditekan');
|
||||
expect(find.byKey(const Key('stopWalkGuideButton')), findsOneWidget,
|
||||
reason: 'Tombol Stop harus muncul menggantikan tombol Start');
|
||||
|
||||
print('[PASS] 2.2 Mulai WalkGuide → status AKTIF');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.3 Obstacle terdeteksi tampil di layar saat WalkGuide aktif',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tunggu simulasi obstacle (300ms delay di stub)
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('obstacleHeader')), findsOneWidget,
|
||||
reason: 'Header obstacle harus muncul saat ada obstacle');
|
||||
|
||||
print('[PASS] 2.3 Obstacle terdeteksi ditampilkan di layar');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.4 Tap "Berhenti WalkGuide" → status kembali TIDAK AKTIF & obstacle hilang',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
|
||||
// Start
|
||||
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('WalkGuide AKTIF'), findsOneWidget);
|
||||
|
||||
// Stop
|
||||
await tester.tap(find.byKey(const Key('stopWalkGuideButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('WalkGuide TIDAK AKTIF'), findsOneWidget,
|
||||
reason: 'Status harus kembali TIDAK AKTIF');
|
||||
expect(find.byKey(const Key('obstacleHeader')), findsNothing,
|
||||
reason: 'Daftar obstacle harus hilang setelah stop');
|
||||
expect(find.byKey(const Key('startWalkGuideButton')), findsOneWidget,
|
||||
reason: 'Tombol Start harus muncul kembali');
|
||||
|
||||
print(
|
||||
'[PASS] 2.4 Stop WalkGuide → status TIDAK AKTIF & obstacle hilang');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.5 Tap tombol SOS dari WalkGuide screen → SOS screen terbuka',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('sosShortcutButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SOS Darurat'), findsOneWidget,
|
||||
reason: 'Harus berpindah ke SOS screen');
|
||||
expect(find.byKey(const Key('sendSosButton')), findsOneWidget);
|
||||
|
||||
print('[PASS] 2.5 Tombol SOS dari WalkGuide → SOS screen terbuka');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.6 Konfirmasi SOS → SOS terkirim dan label "SOS TERKIRIM" muncul',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
await tester.tap(find.byKey(const Key('sosShortcutButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Send SOS button
|
||||
await tester.tap(find.byKey(const Key('sendSosButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog konfirmasi muncul
|
||||
expect(find.text('Konfirmasi SOS'), findsOneWidget,
|
||||
reason: 'Dialog konfirmasi harus muncul');
|
||||
expect(find.byKey(const Key('confirmSosButton')), findsOneWidget);
|
||||
expect(find.byKey(const Key('cancelSosButton')), findsOneWidget);
|
||||
|
||||
// Konfirmasi
|
||||
await tester.tap(find.byKey(const Key('confirmSosButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('sosSentLabel')), findsOneWidget,
|
||||
reason: '"SOS TERKIRIM" harus tampil setelah konfirmasi');
|
||||
expect(find.text('SOS TERKIRIM'), findsOneWidget);
|
||||
// Tombol send tidak lagi tampil setelah SOS terkirim
|
||||
expect(find.byKey(const Key('sendSosButton')), findsNothing,
|
||||
reason: 'Tombol KIRIM SOS tidak boleh tampil setelah SOS terkirim');
|
||||
|
||||
print('[PASS] 2.6 Konfirmasi SOS → "SOS TERKIRIM" muncul');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'2.7 Batal konfirmasi SOS → SOS tidak terkirim, screen tetap sama',
|
||||
(tester) async {
|
||||
await loginAndOpenWalkGuide(tester);
|
||||
await tester.tap(find.byKey(const Key('sosShortcutButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('sendSosButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap batal
|
||||
await tester.tap(find.byKey(const Key('cancelSosButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('sosSentLabel')), findsNothing,
|
||||
reason: 'SOS tidak boleh terkirim jika user menekan Batal');
|
||||
expect(find.byKey(const Key('sendSosButton')), findsOneWidget,
|
||||
reason: 'Tombol KIRIM SOS harus masih ada');
|
||||
|
||||
print('[PASS] 2.7 Batal konfirmasi → SOS tidak terkirim');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,568 @@
|
||||
// integration_test/flow_3_notification_read_all_test.dart
|
||||
//
|
||||
// E2E Flow 3: Login → Notifikasi → Tandai Semua Dibaca → Kembali
|
||||
//
|
||||
// Alur yang diuji:
|
||||
// 1. Login berhasil → Dashboard
|
||||
// 2. Tap ikon notifikasi / tombol "Buka Notifikasi" → NotificationScreen
|
||||
// 3. Unread badge count ditampilkan dengan benar
|
||||
// 4. Tap "Tandai Semua Dibaca" → semua notifikasi berubah menjadi dibaca
|
||||
// 5. Unread badge menjadi 0
|
||||
// 6. Tap kembali → kembali ke Dashboard
|
||||
// 7. Badge di Dashboard juga ter-update menjadi 0
|
||||
//
|
||||
// Jalankan:
|
||||
// flutter test integration_test/flow_3_notification_read_all_test.dart
|
||||
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
// ─── Fake Credentials ────────────────────────────────────────────────────────
|
||||
|
||||
const _kEmail = 'user@walkguide.test';
|
||||
const _kPassword = 'Password123!';
|
||||
|
||||
// ─── Data Models ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _Notification {
|
||||
final int id;
|
||||
final String content;
|
||||
final String sender;
|
||||
bool isRead;
|
||||
|
||||
_Notification({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.sender,
|
||||
this.isRead = false,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Stub State ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _AppState extends ChangeNotifier {
|
||||
bool _loggedIn = false;
|
||||
String _screen = 'login'; // login | dashboard | notification
|
||||
|
||||
final List<_Notification> _notifications = [
|
||||
_Notification(
|
||||
id: 1,
|
||||
content: 'Hati-hati di persimpangan Jalan A',
|
||||
sender: 'Guardian Pak Budi'),
|
||||
_Notification(
|
||||
id: 2,
|
||||
content: 'Cuaca memburuk, segera pulang',
|
||||
sender: 'Guardian Pak Budi'),
|
||||
_Notification(
|
||||
id: 3,
|
||||
content: 'WalkGuide kamu sudah berjalan 2 jam',
|
||||
sender: 'Sistem'),
|
||||
_Notification(
|
||||
id: 4,
|
||||
content: 'Ini notifikasi yang sudah dibaca',
|
||||
sender: 'Sistem',
|
||||
isRead: true),
|
||||
];
|
||||
|
||||
bool get loggedIn => _loggedIn;
|
||||
String get screen => _screen;
|
||||
List<_Notification> get notifications => List.unmodifiable(_notifications);
|
||||
int get unreadCount => _notifications.where((n) => !n.isRead).length;
|
||||
|
||||
Future<bool> login(String email, String password) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
if (email == _kEmail && password == _kPassword) {
|
||||
_loggedIn = true;
|
||||
_screen = 'dashboard';
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void openNotifications() {
|
||||
_screen = 'notification';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void goToDashboard() {
|
||||
_screen = 'dashboard';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void markAsRead(int id) {
|
||||
final n = _notifications.firstWhere((n) => n.id == id);
|
||||
if (!n.isRead) {
|
||||
n.isRead = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markAllAsRead() async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
for (final n in _notifications) {
|
||||
n.isRead = true;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stub App ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StubApp extends StatefulWidget {
|
||||
const _StubApp();
|
||||
|
||||
@override
|
||||
State<_StubApp> createState() => _StubAppState();
|
||||
}
|
||||
|
||||
class _StubAppState extends State<_StubApp> {
|
||||
final _state = _AppState();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'WalkGuide Test',
|
||||
home: ListenableBuilder(
|
||||
listenable: _state,
|
||||
builder: (context, _) {
|
||||
switch (_state.screen) {
|
||||
case 'notification':
|
||||
return _NotificationScreen(state: _state);
|
||||
case 'dashboard':
|
||||
return _DashboardScreen(state: _state);
|
||||
default:
|
||||
return _LoginScreen(state: _state);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Login Screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _LoginScreen extends StatefulWidget {
|
||||
final _AppState state;
|
||||
const _LoginScreen({required this.state});
|
||||
|
||||
@override
|
||||
State<_LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<_LoginScreen> {
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _loading = true);
|
||||
await widget.state.login(_emailCtrl.text.trim(), _passwordCtrl.text.trim());
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Login')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(children: [
|
||||
TextField(
|
||||
key: const Key('emailField'),
|
||||
controller: _emailCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('passwordField'),
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator()
|
||||
else
|
||||
ElevatedButton(
|
||||
key: const Key('loginButton'),
|
||||
onPressed: _submit,
|
||||
child: const Text('Login'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardScreen extends StatelessWidget {
|
||||
final _AppState state;
|
||||
const _DashboardScreen({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = state.unreadCount;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dashboard'),
|
||||
actions: [
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
IconButton(
|
||||
key: const Key('notifIconButton'),
|
||||
icon: const Icon(Icons.notifications),
|
||||
tooltip: 'Notifikasi',
|
||||
onPressed: state.openNotifications,
|
||||
),
|
||||
if (unread > 0)
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Container(
|
||||
key: const Key('dashboardBadge'),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$unread',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Text('Selamat datang di Dashboard'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
key: const Key('openNotifButton'),
|
||||
onPressed: state.openNotifications,
|
||||
child: Text(
|
||||
unread > 0
|
||||
? 'Lihat Notifikasi ($unread belum dibaca)'
|
||||
: 'Lihat Notifikasi',
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notification Screen ──────────────────────────────────────────────────────
|
||||
|
||||
class _NotificationScreen extends StatelessWidget {
|
||||
final _AppState state;
|
||||
const _NotificationScreen({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notifs = state.notifications;
|
||||
final unread = state.unreadCount;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notifikasi'),
|
||||
leading: IconButton(
|
||||
key: const Key('notifBackButton'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: state.goToDashboard,
|
||||
),
|
||||
actions: [
|
||||
if (unread > 0)
|
||||
TextButton(
|
||||
key: const Key('markAllReadButton'),
|
||||
onPressed: state.markAllAsRead,
|
||||
child: const Text(
|
||||
'Tandai Semua Dibaca',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(children: [
|
||||
// Unread count info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Text(
|
||||
'$unread notifikasi belum dibaca',
|
||||
key: const Key('unreadCountLabel'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (unread == 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.check_circle, color: Colors.green, size: 18),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Notification list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
key: const Key('notifList'),
|
||||
itemCount: notifs.length,
|
||||
itemBuilder: (_, i) {
|
||||
final n = notifs[i];
|
||||
return ListTile(
|
||||
key: Key('notifItem_${n.id}'),
|
||||
leading: Icon(
|
||||
n.isRead ? Icons.mark_email_read : Icons.email,
|
||||
color: n.isRead ? Colors.grey : Colors.blue,
|
||||
),
|
||||
title: Text(n.content),
|
||||
subtitle: Text('Dari: ${n.sender}'),
|
||||
tileColor: n.isRead ? null : Colors.blue.shade50,
|
||||
trailing: n.isRead
|
||||
? const Text('Dibaca',
|
||||
key: Key('readLabel'),
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12))
|
||||
: TextButton(
|
||||
onPressed: () => state.markAsRead(n.id),
|
||||
child: const Text('Tandai Dibaca'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Flow 3 — Login → Notifikasi → Tandai Semua Dibaca → Kembali', () {
|
||||
/// Helper: login
|
||||
Future<void> loginAndGoToDashboard(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const _StubApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(const Key('emailField')), _kEmail);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('passwordField')), _kPassword);
|
||||
await tester.tap(find.byKey(const Key('loginButton')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'3.1 Dashboard menampilkan badge jumlah notifikasi belum dibaca',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
// Ada 3 notifikasi unread (id 1, 2, 3); id 4 sudah read
|
||||
expect(find.byKey(const Key('dashboardBadge')), findsOneWidget,
|
||||
reason: 'Badge harus muncul karena ada notif belum dibaca');
|
||||
expect(find.text('3'), findsOneWidget,
|
||||
reason: 'Badge harus menampilkan angka 3');
|
||||
|
||||
print(
|
||||
'[PASS] 3.1 Dashboard badge menampilkan jumlah unread yang benar');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.2 Tap ikon notifikasi → NotificationScreen terbuka dengan daftar notif',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Notifikasi'), findsOneWidget,
|
||||
reason: 'Harus masuk ke NotificationScreen');
|
||||
expect(find.byKey(const Key('notifList')), findsOneWidget,
|
||||
reason: 'ListView notifikasi harus tampil');
|
||||
|
||||
// 4 notifikasi harus tampil
|
||||
expect(find.byKey(const Key('notifItem_1')), findsOneWidget);
|
||||
expect(find.byKey(const Key('notifItem_2')), findsOneWidget);
|
||||
expect(find.byKey(const Key('notifItem_3')), findsOneWidget);
|
||||
expect(find.byKey(const Key('notifItem_4')), findsOneWidget);
|
||||
|
||||
print('[PASS] 3.2 NotificationScreen terbuka dengan semua notifikasi');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.3 Label unread count tampil dengan angka yang benar di NotificationScreen',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('unreadCountLabel')), findsOneWidget);
|
||||
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget,
|
||||
reason: 'Harus menampilkan "3 notifikasi belum dibaca"');
|
||||
|
||||
print('[PASS] 3.3 Unread count label menampilkan angka yang benar');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.4 Tombol "Tandai Semua Dibaca" muncul saat ada notif belum dibaca',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('markAllReadButton')), findsOneWidget,
|
||||
reason: 'Tombol "Tandai Semua Dibaca" harus tampil');
|
||||
|
||||
print('[PASS] 3.4 Tombol "Tandai Semua Dibaca" tampil');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.5 Tap "Tandai Semua Dibaca" → semua notif jadi dibaca, count jadi 0',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Sebelum: 3 unread
|
||||
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('markAllReadButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Setelah: 0 unread
|
||||
expect(find.text('0 notifikasi belum dibaca'), findsOneWidget,
|
||||
reason: 'Setelah tandai semua, count harus 0');
|
||||
expect(find.byKey(const Key('markAllReadButton')), findsNothing,
|
||||
reason: 'Tombol "Tandai Semua" harus hilang saat tidak ada unread');
|
||||
|
||||
print('[PASS] 3.5 Tandai semua dibaca → count menjadi 0');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.6 Tandai satu notif dibaca → count berkurang 1',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget);
|
||||
|
||||
// Scroll ke notif pertama yang unread dan tandai
|
||||
final markButtons = find.text('Tandai Dibaca');
|
||||
expect(markButtons, findsWidgets);
|
||||
await tester.tap(markButtons.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 notifikasi belum dibaca'), findsOneWidget,
|
||||
reason: 'Count harus berkurang menjadi 2');
|
||||
|
||||
print('[PASS] 3.6 Tandai satu notif → count berkurang 1');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.7 Kembali ke Dashboard → badge ter-update menjadi 0 setelah tandai semua',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
// Buka notifikasi
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tandai semua dibaca
|
||||
await tester.tap(find.byKey(const Key('markAllReadButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Kembali ke dashboard
|
||||
await tester.tap(find.byKey(const Key('notifBackButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dashboard'), findsOneWidget,
|
||||
reason: 'Harus kembali ke Dashboard');
|
||||
expect(find.byKey(const Key('dashboardBadge')), findsNothing,
|
||||
reason: 'Badge harus hilang karena semua sudah dibaca');
|
||||
expect(find.text('Lihat Notifikasi'), findsOneWidget,
|
||||
reason: 'Tombol notifikasi harus tanpa keterangan "belum dibaca"');
|
||||
|
||||
print('[PASS] 3.7 Kembali ke Dashboard → badge hilang');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.8 Buka notifikasi via tombol di body Dashboard juga berfungsi',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('openNotifButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Notifikasi'), findsOneWidget,
|
||||
reason:
|
||||
'Tombol di body Dashboard juga harus membuka NotificationScreen');
|
||||
|
||||
print('[PASS] 3.8 Tombol notif di body Dashboard berfungsi');
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'3.9 Full round-trip: Login → Notif → Tandai Semua → Kembali → Badge = 0',
|
||||
(tester) async {
|
||||
await loginAndGoToDashboard(tester);
|
||||
|
||||
// Verify badge ada
|
||||
expect(find.byKey(const Key('dashboardBadge')), findsOneWidget);
|
||||
|
||||
// Buka notifikasi
|
||||
await tester.tap(find.byKey(const Key('notifIconButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Notifikasi'), findsOneWidget);
|
||||
|
||||
// Tandai semua
|
||||
await tester.tap(find.byKey(const Key('markAllReadButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('0 notifikasi belum dibaca'), findsOneWidget);
|
||||
|
||||
// Kembali
|
||||
await tester.tap(find.byKey(const Key('notifBackButton')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
expect(find.byKey(const Key('dashboardBadge')), findsNothing,
|
||||
reason: 'Setelah semua dibaca, badge harus hilang dari Dashboard');
|
||||
|
||||
print('[PASS] 3.9 Full round-trip notifikasi berhasil');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user