From 4a0ae1d6156babf61b8cc62653e99d29ffc21fc8 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Sun, 17 May 2026 02:18:29 +0700 Subject: [PATCH] test(integration): add e2e flows for flutter app and java backend features --- .../integration/AbstractIntegrationTest.java | 143 +++ .../integration/AuthIntegrationTest.java | 294 +++++ .../integration/PairingIntegrationTest.java | 286 +++++ .../UserFeatureIntegrationTest.java | 488 ++++++++ .../test/integration_test/app_flow_test.dart | 1005 +++++++++++++++++ .../flow_1_login_dashboard_logout_test.dart | 383 +++++++ .../flow_2_walkguide_start_stop_sos_test.dart | 580 ++++++++++ .../flow_3_notification_read_all_test.dart | 568 ++++++++++ 8 files changed, 3747 insertions(+) create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/integration/AbstractIntegrationTest.java create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java create mode 100644 walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/integration_test/flow_1_login_dashboard_logout_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/AbstractIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AbstractIntegrationTest.java new file mode 100644 index 0000000..c796500 --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AbstractIntegrationTest.java @@ -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; + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java new file mode 100644 index 0000000..fb877ea --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java @@ -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)); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java new file mode 100644 index 0000000..f086d76 --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java new file mode 100644 index 0000000..37e099b --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart new file mode 100644 index 0000000..008684a --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart @@ -0,0 +1,1005 @@ +// integration_test/app_flow_test.dart +// +// Integration Tests (E2E) untuk WalkGuide App — 3 alur utama: +// Flow 1: Login → Dashboard → Logout +// Flow 2: Login → WalkGuide → Start → Stop → SOS +// Flow 3: Login → Notifikasi → Tandai Semua Dibaca → Kembali +// +// Jalankan di device fisik: +// flutter test integration_test/app_flow_test.dart +// atau: +// flutter drive --driver=test_driver/integration_test.dart \ +// --target=integration_test/app_flow_test.dart +// +// CATATAN: +// Integration test ini menggunakan stub backend lokal untuk simulasi +// HTTP response sehingga bisa dijalankan tanpa koneksi ke server kampus. +// Untuk E2E full terhadap server live, lihat bagian LIVE TEST di bawah. +// +// Dev dependencies (pubspec.yaml): +// integration_test: +// sdk: flutter +// flutter_test: +// sdk: flutter +// mockito: ^5.4.4 + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +// --------------------------------------------------------------------------- +// Minimal App stub untuk integration test tanpa full DI stack +// --------------------------------------------------------------------------- +// +// PENTING: Dalam project nyata, ganti _IntegrationTestApp dengan: +// import 'package:walkguide_app/main.dart' as app; +// void main() { app.main(); } +// +// Kemudian mock service layer (ApiClient, SecureStorage) dengan Mockito +// agar test tidak perlu koneksi internet. +// +// Untuk saat ini kita pakai stub app yang self-contained supaya test +// bisa langsung run tanpa setup penuh. + +// --------------------------------------------------------------------------- +// ─── Shared Models ───────────────────────────────────────────────────────── +// --------------------------------------------------------------------------- + +class FakeUser { + final String email; + final String password; + final String role; + final String token; + + const FakeUser({ + required this.email, + required this.password, + required this.role, + required this.token, + }); +} + +const _fakeUserAccount = FakeUser( + email: 'user@walkguide.test', + password: 'Password123!', + role: 'ROLE_USER', + token: 'fake-jwt-token-user-xxx', +); + +const _fakeGuardianAccount = FakeUser( + email: 'guardian@walkguide.test', + password: 'Password123!', + role: 'ROLE_GUARDIAN', + token: 'fake-jwt-token-guardian-xxx', +); + +// --------------------------------------------------------------------------- +// ─── Stub App ─────────────────────────────────────────────────────────────── +// --------------------------------------------------------------------------- + +/// State global sederhana (pengganti DI Container di test) +class _AppState extends ChangeNotifier { + FakeUser? _currentUser; + bool _walkGuideActive = false; + bool _sosSent = false; + List<_NotifItem> _notifications = [ + _NotifItem(id: 1, content: 'Hati-hati di persimpangan', isRead: false), + _NotifItem(id: 2, content: 'Cuaca memburuk hari ini', isRead: false), + _NotifItem(id: 3, content: 'Pesan lama sudah dibaca', isRead: true), + ]; + + FakeUser? get currentUser => _currentUser; + bool get walkGuideActive => _walkGuideActive; + bool get sosSent => _sosSent; + List<_NotifItem> get notifications => _notifications; + int get unreadCount => _notifications.where((n) => !n.isRead).length; + + /// Simulasi login: return true jika credentials cocok + Future login(String email, String password) async { + await Future.delayed(const Duration(milliseconds: 300)); + if (email == _fakeUserAccount.email && + password == _fakeUserAccount.password) { + _currentUser = _fakeUserAccount; + notifyListeners(); + return true; + } + if (email == _fakeGuardianAccount.email && + password == _fakeGuardianAccount.password) { + _currentUser = _fakeGuardianAccount; + notifyListeners(); + return true; + } + return false; + } + + void logout() { + _currentUser = null; + _walkGuideActive = false; + _sosSent = false; + notifyListeners(); + } + + void toggleWalkGuide() { + _walkGuideActive = !_walkGuideActive; + notifyListeners(); + } + + Future sendSos() async { + await Future.delayed(const Duration(milliseconds: 200)); + _sosSent = true; + notifyListeners(); + } + + void markAllRead() { + _notifications = _notifications + .map((n) => _NotifItem(id: n.id, content: n.content, isRead: true)) + .toList(); + notifyListeners(); + } +} + +class _NotifItem { + final int id; + final String content; + final bool isRead; + const _NotifItem( + {required this.id, required this.content, required this.isRead}); +} + +// ── Screens ──────────────────────────────────────────────────────────────── + +class _LoginScreen extends StatefulWidget { + const _LoginScreen(); + + @override + State<_LoginScreen> createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State<_LoginScreen> { + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _loading = false; + String? _error; + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final state = _AppStateProvider.of(context); + setState(() { + _loading = true; + _error = null; + }); + final ok = await state.login(_emailCtrl.text.trim(), _passCtrl.text); + if (!mounted) return; + setState(() => _loading = false); + if (ok) { + Navigator.of(context).pushReplacementNamed('/dashboard'); + } else { + setState(() => _error = 'Email atau password salah'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('WalkGuide', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + if (_error != null) + Container( + key: const Key('login_error'), + padding: const EdgeInsets.all(12), + color: Colors.red.shade50, + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + TextField( + key: const Key('email_field'), + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 16), + TextField( + key: const Key('password_field'), + controller: _passCtrl, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + const SizedBox(height: 24), + _loading + ? const Center( + child: CircularProgressIndicator(key: Key('login_loading'))) + : ElevatedButton( + key: const Key('login_button'), + onPressed: _submit, + child: const Text('Masuk'), + ), + ], + ), + ), + ); + } +} + +class _DashboardScreen extends StatelessWidget { + const _DashboardScreen(); + + @override + Widget build(BuildContext context) { + final state = _AppStateProvider.of(context); + return ListenableBuilder( + listenable: state, + builder: (context, _) { + final user = state.currentUser; + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + TextButton( + key: const Key('logout_button'), + onPressed: () { + state.logout(); + Navigator.of(context).pushReplacementNamed('/login'); + }, + child: const Text('Keluar'), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + key: const Key('user_card'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: const Key('user_role_text'), + 'Role: ${user?.role ?? '-'}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + key: const Key('user_email_text'), + 'Email: ${user?.email ?? '-'}', + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + key: const Key('go_walkguide_button'), + onPressed: () => + Navigator.of(context).pushNamed('/walkguide'), + icon: const Icon(Icons.directions_walk), + label: const Text('WalkGuide'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + key: const Key('go_notifications_button'), + onPressed: () => + Navigator.of(context).pushNamed('/notifications'), + icon: const Icon(Icons.notifications), + label: Stack( + children: [ + const Text('Notifikasi'), + if (state.unreadCount > 0) + Positioned( + right: -4, + top: -4, + child: Container( + key: const Key('dashboard_unread_badge'), + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: Text( + '${state.unreadCount}', + style: const TextStyle( + color: Colors.white, fontSize: 10), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + key: const Key('go_sos_button'), + onPressed: () => Navigator.of(context).pushNamed('/sos'), + icon: const Icon(Icons.sos, color: Colors.red), + label: const Text('SOS'), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _WalkGuideScreen extends StatelessWidget { + const _WalkGuideScreen(); + + @override + Widget build(BuildContext context) { + final state = _AppStateProvider.of(context); + return ListenableBuilder( + listenable: state, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('WalkGuide')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + key: const Key('wg_status_bar'), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: state.walkGuideActive ? Colors.green : Colors.grey, + borderRadius: BorderRadius.circular(24), + ), + child: Text( + key: const Key('wg_status_text'), + state.walkGuideActive ? 'AKTIF' : 'TIDAK AKTIF', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18), + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + key: const Key('wg_toggle_button'), + onPressed: state.toggleWalkGuide, + icon: Icon( + state.walkGuideActive ? Icons.stop : Icons.play_arrow), + label: Text(state.walkGuideActive + ? 'Stop WalkGuide' + : 'Start WalkGuide'), + style: ElevatedButton.styleFrom( + backgroundColor: + state.walkGuideActive ? Colors.red : Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(200, 52), + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + key: const Key('wg_sos_button'), + onPressed: () => Navigator.of(context).pushNamed('/sos'), + icon: const Icon(Icons.sos, color: Colors.red), + label: const Text('SOS', style: TextStyle(color: Colors.red)), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _SosScreen extends StatelessWidget { + const _SosScreen(); + + @override + Widget build(BuildContext context) { + final state = _AppStateProvider.of(context); + return ListenableBuilder( + listenable: state, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('SOS')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (state.sosSent) + Container( + key: const Key('sos_sent_banner'), + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: Colors.green), + SizedBox(width: 8), + Text( + key: Key('sos_sent_text'), + 'SOS berhasil dikirim!', + style: TextStyle( + color: Colors.green, fontWeight: FontWeight.bold), + ), + ], + ), + ), + GestureDetector( + onTap: state.sendSos, + child: Container( + key: const Key('sos_main_button'), + width: 160, + height: 160, + decoration: BoxDecoration( + color: state.sosSent ? Colors.grey : Colors.red, + shape: BoxShape.circle, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.sos, color: Colors.white, size: 52), + Text( + state.sosSent ? 'TERKIRIM' : 'SOS', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _NotificationScreen extends StatelessWidget { + const _NotificationScreen(); + + @override + Widget build(BuildContext context) { + final state = _AppStateProvider.of(context); + return ListenableBuilder( + listenable: state, + builder: (context, _) { + final notifs = state.notifications; + final hasUnread = notifs.any((n) => !n.isRead); + return Scaffold( + appBar: AppBar( + title: const Text('Notifikasi'), + actions: [ + if (hasUnread) + TextButton( + key: const Key('mark_all_read_button'), + onPressed: state.markAllRead, + child: const Text('Tandai Semua'), + ), + ], + ), + body: ListView.separated( + key: const Key('notif_list'), + itemCount: notifs.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final n = notifs[i]; + return ListTile( + key: Key('notif_item_${n.id}'), + title: Text(n.content), + trailing: n.isRead + ? null + : Container( + key: Key('notif_unread_${n.id}'), + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + ); + }, + ), + ); + }, + ); + } +} + +// ── InheritedWidget untuk state ──────────────────────────────────────────── + +class _AppStateProvider extends InheritedNotifier<_AppState> { + const _AppStateProvider({required super.notifier, required super.child}); + + static _AppState of(BuildContext context) { + final provider = + context.dependOnInheritedWidgetOfExactType<_AppStateProvider>(); + assert(provider != null, '_AppStateProvider not found in widget tree'); + return provider!.notifier!; + } +} + +// ── Root App ─────────────────────────────────────────────────────────────── + +class _IntegrationTestApp extends StatelessWidget { + final _AppState _state = _AppState(); + + _IntegrationTestApp(); + + @override + Widget build(BuildContext context) { + return _AppStateProvider( + notifier: _state, + child: MaterialApp( + initialRoute: '/login', + routes: { + '/login': (_) => const _LoginScreen(), + '/dashboard': (_) => const _DashboardScreen(), + '/walkguide': (_) => const _WalkGuideScreen(), + '/sos': (_) => const _SosScreen(), + '/notifications': (_) => const _NotificationScreen(), + }, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// ─── Integration Tests ───────────────────────────────────────────────────── +// --------------------------------------------------------------------------- + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // ══════════════════════════════════════════════════════════════════════════ + // FLOW 1: Login → Dashboard → Logout + // ══════════════════════════════════════════════════════════════════════════ + group('Flow 1: Login → Dashboard → Logout', () { + testWidgets('1.1 - halaman login tampil pertama kali', (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + expect(find.text('Login'), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('email_field')), findsOneWidget); + expect(find.byKey(const Key('password_field')), findsOneWidget); + expect(find.byKey(const Key('login_button')), findsOneWidget); + }); + + testWidgets('1.2 - login dengan credentials salah menampilkan error', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), 'wrong@email.com'); + await tester.enterText( + find.byKey(const Key('password_field')), 'wrongpass'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.text('Email atau password salah'), findsOneWidget); + }); + + testWidgets('1.3 - login berhasil redirect ke Dashboard', (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Dashboard'), findsOneWidget); + expect(find.byKey(const Key('user_card')), findsOneWidget); + }); + + testWidgets('1.4 - dashboard menampilkan role user yang benar', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('ROLE_USER'), findsOneWidget); + }); + + testWidgets('1.5 - dashboard menampilkan email user yang login', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.textContaining(_fakeUserAccount.email), findsOneWidget); + }); + + testWidgets('1.6 - logout kembali ke halaman login', (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + // Login + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Dashboard'), findsOneWidget); + + // Logout + await tester.tap(find.byKey(const Key('logout_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('Dashboard'), findsNothing); + }); + + testWidgets('1.7 - guardian login juga masuk ke dashboard', (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeGuardianAccount.email); + await tester.enterText(find.byKey(const Key('password_field')), + _fakeGuardianAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Dashboard'), findsOneWidget); + expect(find.textContaining('ROLE_GUARDIAN'), findsOneWidget); + }); + + testWidgets('1.8 - dashboard menampilkan tombol navigasi ke fitur utama', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('go_walkguide_button')), findsOneWidget); + expect(find.byKey(const Key('go_notifications_button')), findsOneWidget); + expect(find.byKey(const Key('go_sos_button')), findsOneWidget); + }); + }); + + // ══════════════════════════════════════════════════════════════════════════ + // FLOW 2: Login → WalkGuide → Start → Stop → SOS + // ══════════════════════════════════════════════════════════════════════════ + group('Flow 2: Login → WalkGuide → Start → Stop → SOS', () { + /// Helper: Login dan navigasi ke WalkGuide + Future _loginAndGoToWalkGuide(WidgetTester tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('go_walkguide_button'))); + await tester.pumpAndSettle(); + } + + testWidgets('2.1 - navigasi dari Dashboard ke WalkGuide berhasil', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + expect(find.text('WalkGuide'), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('wg_toggle_button')), findsOneWidget); + }); + + testWidgets('2.2 - status awal WalkGuide adalah TIDAK AKTIF', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + expect(find.text('TIDAK AKTIF'), findsOneWidget); + expect(find.text('Start WalkGuide'), findsOneWidget); + }); + + testWidgets('2.3 - tap Start mengubah status menjadi AKTIF', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + await tester.tap(find.byKey(const Key('wg_toggle_button'))); + await tester.pumpAndSettle(); + + expect(find.text('AKTIF'), findsOneWidget); + expect(find.text('Stop WalkGuide'), findsOneWidget); + }); + + testWidgets('2.4 - status bar berwarna hijau saat WalkGuide aktif', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + await tester.tap(find.byKey(const Key('wg_toggle_button'))); + await tester.pumpAndSettle(); + + final statusBar = + tester.widget(find.byKey(const Key('wg_status_bar'))); + final decoration = statusBar.decoration as BoxDecoration; + expect(decoration.color, Colors.green); + }); + + testWidgets('2.5 - tap Stop mengembalikan status ke TIDAK AKTIF', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + // Start + await tester.tap(find.byKey(const Key('wg_toggle_button'))); + await tester.pumpAndSettle(); + expect(find.text('AKTIF'), findsOneWidget); + + // Stop + await tester.tap(find.byKey(const Key('wg_toggle_button'))); + await tester.pumpAndSettle(); + expect(find.text('TIDAK AKTIF'), findsOneWidget); + }); + + testWidgets('2.6 - tombol Start/Stop dapat di-toggle berulang kali', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + for (int i = 0; i < 3; i++) { + await tester.tap(find.byKey(const Key('wg_toggle_button'))); + await tester.pumpAndSettle(); + } + + // Setelah 3 kali tap: aktif → tidak aktif → aktif + expect(find.text('AKTIF'), findsOneWidget); + }); + + testWidgets('2.7 - tombol SOS tersedia di layar WalkGuide', (tester) async { + await _loginAndGoToWalkGuide(tester); + + expect(find.byKey(const Key('wg_sos_button')), findsOneWidget); + }); + + testWidgets('2.8 - tap tombol SOS dari WalkGuide navigasi ke SOS screen', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + await tester.tap(find.byKey(const Key('wg_sos_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('sos_main_button')), findsOneWidget); + }); + + testWidgets('2.9 - tap tombol SOS di SOS screen mengirim SOS', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + await tester.tap(find.byKey(const Key('wg_sos_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sos_main_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('sos_sent_banner')), findsOneWidget); + expect(find.text('SOS berhasil dikirim!'), findsOneWidget); + }); + + testWidgets( + '2.10 - setelah SOS terkirim, tombol SOS berubah label TERKIRIM', + (tester) async { + await _loginAndGoToWalkGuide(tester); + + await tester.tap(find.byKey(const Key('wg_sos_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('sos_main_button'))); + await tester.pumpAndSettle(); + + expect(find.text('TERKIRIM'), findsOneWidget); + }); + }); + + // ══════════════════════════════════════════════════════════════════════════ + // FLOW 3: Login → Notifikasi → Tandai Semua → Kembali ke Dashboard + // ══════════════════════════════════════════════════════════════════════════ + group('Flow 3: Login → Notifikasi → Mark All Read → Kembali', () { + /// Helper: Login dan navigasi ke Notifikasi + Future _loginAndGoToNotifications(WidgetTester tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('go_notifications_button'))); + await tester.pumpAndSettle(); + } + + testWidgets('3.1 - navigasi ke Notifikasi dari Dashboard berhasil', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.text('Notifikasi'), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('notif_list')), findsOneWidget); + }); + + testWidgets('3.2 - notifikasi yang ada ditampilkan dalam list', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.byKey(const Key('notif_item_1')), findsOneWidget); + expect(find.byKey(const Key('notif_item_2')), findsOneWidget); + expect(find.byKey(const Key('notif_item_3')), findsOneWidget); + }); + + testWidgets('3.3 - notifikasi belum dibaca menampilkan dot biru', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.byKey(const Key('notif_unread_1')), findsOneWidget); + expect(find.byKey(const Key('notif_unread_2')), findsOneWidget); + }); + + testWidgets('3.4 - notifikasi sudah dibaca tidak menampilkan dot', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.byKey(const Key('notif_unread_3')), findsNothing); + }); + + testWidgets('3.5 - tombol Tandai Semua tersedia saat ada unread', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.byKey(const Key('mark_all_read_button')), findsOneWidget); + }); + + testWidgets('3.6 - dashboard menampilkan unread badge sebelum mark all', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('dashboard_unread_badge')), findsOneWidget); + }); + + testWidgets('3.7 - tap Tandai Semua menghapus semua dot unread', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.byKey(const Key('notif_unread_1')), findsOneWidget); + + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notif_unread_1')), findsNothing); + expect(find.byKey(const Key('notif_unread_2')), findsNothing); + }); + + testWidgets('3.8 - setelah mark all, tombol Tandai Semua hilang', + (tester) async { + await _loginAndGoToNotifications(tester); + + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mark_all_read_button')), findsNothing); + }); + + testWidgets('3.9 - kembali ke dashboard setelah dari notifikasi', + (tester) async { + await _loginAndGoToNotifications(tester); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(find.text('Dashboard'), findsOneWidget); + }); + + testWidgets('3.10 - konten notifikasi ditampilkan dengan benar', + (tester) async { + await _loginAndGoToNotifications(tester); + + expect(find.text('Hati-hati di persimpangan'), findsOneWidget); + expect(find.text('Cuaca memburuk hari ini'), findsOneWidget); + expect(find.text('Pesan lama sudah dibaca'), findsOneWidget); + }); + + testWidgets( + '3.11 - state perubahan notifikasi persisten saat kembali ke dashboard', + (tester) async { + await _loginAndGoToNotifications(tester); + + // Mark all read di halaman notifikasi + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pumpAndSettle(); + + // Kembali ke dashboard + await tester.pageBack(); + await tester.pumpAndSettle(); + + // Badge unread harus hilang karena sudah di-mark all + expect(find.byKey(const Key('dashboard_unread_badge')), findsNothing); + }); + + testWidgets('3.12 - full flow: login → notifikasi → mark all → logout', + (tester) async { + await tester.pumpWidget(_IntegrationTestApp()); + await tester.pumpAndSettle(); + + // Step 1: Login + await tester.enterText( + find.byKey(const Key('email_field')), _fakeUserAccount.email); + await tester.enterText( + find.byKey(const Key('password_field')), _fakeUserAccount.password); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + expect(find.text('Dashboard'), findsOneWidget); + + // Step 2: Buka notifikasi + await tester.tap(find.byKey(const Key('go_notifications_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('notif_list')), findsOneWidget); + + // Step 3: Mark all read + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('notif_unread_1')), findsNothing); + + // Step 4: Kembali ke dashboard + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('Dashboard'), findsOneWidget); + + // Step 5: Logout + await tester.tap(find.byKey(const Key('logout_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('login_button')), findsOneWidget); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/integration_test/flow_1_login_dashboard_logout_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/flow_1_login_dashboard_logout_test.dart new file mode 100644 index 0000000..674e40e --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/integration_test/flow_1_login_dashboard_logout_test.dart @@ -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 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 _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'); + }, + ); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart new file mode 100644 index 0000000..168654c --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart @@ -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 _detectedObstacles = []; + + bool get loggedIn => _loggedIn; + String get currentScreen => _currentScreen; + _WalkGuideStatus get walkGuideStatus => _walkGuideStatus; + _SosStatus get sosStatus => _sosStatus; + List get detectedObstacles => _detectedObstacles; + + Future 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 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 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 _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( + 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 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'); + }, + ); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart new file mode 100644 index 0000000..f17730e --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart @@ -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 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 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 _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 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'); + }, + ); + }); +}