diff --git a/.maestro/README-profile.md b/.maestro/README-profile.md new file mode 100644 index 00000000..7b1c03d1 --- /dev/null +++ b/.maestro/README-profile.md @@ -0,0 +1,75 @@ +# Profile Maestro CI + +`.maestro/suites/profile.json` registers four real authenticated flows: + +| Flow | Required behavior | +| --- | --- | +| `profile-edit` | Check nickname availability, save nickname/bio, read persisted values, restore through the UI, and read the original values. | +| `profile-edit-validation` | Reject unchecked nicknames, invalid input, and a nickname changed after availability confirmation. No profile write is permitted. | +| `profile-preferred-exercise` | Reject incomplete/empty selections, create one real catalog preference, persist Monday/BEGINNER, add Wednesday, remove Monday, verify both changes after re-entry, and delete only the added preference. | +| `profile-notification` | Persist OFF→ON, deny the actual Android notification permission, show OFF without writing server settings, grant permission, read the persisted ON values, and persist OFF again. | + +The shared runner selects this descriptor with `--suite profile`; `--suite ci` discovers it automatically. **API33 is the functional minimum** for the notification-permission scenario. The descriptor explicitly selects **API34** as the CI candidate and keeps `fixture: true`. All four flows and the complete denied-permission branch are mandatory. API30 cannot exercise this behavior. Use the shared runner's disposable CI emulator, installed E2E APK, required ABI, real DEV/Firebase configuration, and existing private authentication bootstrap. Do not invoke these YAML files directly against a personal account. + +The API34 `google_apis`/`x86_64` candidate has source evidence: Google's [official `build.prop`](https://android.googlesource.com/platform/prebuilts/android-emulator-build/system-images/+/fd510d08d1ee747159b42cc05a22d8e65eade830/generic/system-images/android-34/google_apis/x86_64/build.prop?format=HTML) declares `arm64-v8a`, `libndk_translation.so`, and `userdebug`. The matching [source.properties](https://android.googlesource.com/platform/prebuilts/android-emulator-build/system-images/+/fd510d08d1ee747159b42cc05a22d8e65eade830/generic/system-images/android-34/google_apis/x86_64/source.properties?format=HTML) identifies historical image revision 8, not the current CI image. This evidence does not guarantee runtime compatibility. No successful API34 profile CI run is established. The installed image must still pass the existing root, locale, input, ABI, and installation guards, followed by all four authenticated flows and cleanup. No image override or automatic fallback replaces those checks. + +## Real prerequisites + +The existing `MAESTRO_AUTH_STATE_JSON`, `MAESTRO_MEMBER_ID`, and `MAESTRO_FIXTURE_START_DATE` inputs are required by the shared lifecycle. There are no additional profile secrets, fake FCM tokens, or fixed exercise IDs. + +Preparation reads the authenticated member's real profile and preference records before any feature write. Only `profile-notification` requires device registration and notification settings. Any unsuccessful response or missing required data fails preparation. + +For failed profile preparation, the runner reports only exact fixed codes reviewed in `MaestroProfileFixture`, `MaestroProfilePolicy`, and `MaestroProfileNotificationReadiness`. Baseline validation reports the first failed condition in this order: `profile_baseline_nickname_unsupported`, `profile_baseline_gender_unsupported`, `profile_baseline_bio_null`, `profile_baseline_bio_too_long`, then `profile_baseline_bio_control_characters`. The runner maps each code to its fixed `bootstrap-prepare-failed-profile-baseline-...` error. The diagnostic mapping applies only to `prepare` + `profile` + `status: failed`. Unknown strings, prefixes with extra text, non-string reasons, the retired `profile_baseline_text_unsupported` code, other features, cleanup, and other statuses retain the generic bootstrap error. Diagnostics never include profile values, lengths, character codes, tokens, exception messages, or arbitrary server responses. The transport regression tests retain the synthetic-secret redaction case and cover all five baseline mappings and these boundaries. + +- The original nickname must pass the app's `ValidateNicknameUseCase` rule (`^[가-힣a-zA-Z0-9]{1,30}$`), because UI restoration runs the real duplicate check. The original gender must be one of the app's `MALE`, `FEMALE`, or `UNKNOWN` values. +- The bio must be non-null, at most 50 UTF-16 characters, and contain no control characters. An empty string is a valid baseline, distinct from null. After `profile_edit_loaded`, the edit flow checks the introduction's actual empty/present state. It never copies an empty field's hint or calls `inputText` with an empty value. Nonempty baselines still require exact `copyTextFrom` equality. UI restoration selects and clears the entire field, types only a nonempty original, verifies both actual inputs before saving, reopens the editor, waits for its profile read, and checks the actual empty state or exact original content. Maestro CLI 2.11.0 types non-ASCII Android `inputText` through its own IME, so Hangul and punctuation remain supported. Null cannot be sent in the profile PUT body. Null and unrepresentable baselines fail **before mutation**; the fixture never replaces the account baseline with hint or test text. +- Every original preference must have a positive unique record ID/type ID, seven day booleans with at least one true day, and a recognized skill. The preference scenario requires 1–4 original selections and at least one real unselected catalog type. Preparation obtains its ID/name from the real catalog; the flow never chooses an arbitrary offscreen chip. +- For `profile-notification`, the fixture waits for the real saved FCM token, registers that token, and reads the actual device settings. The response must contain exactly one `WORKOUT_REMINDER` and one `CHAT_MESSAGE` row. Missing rows fail; the fixture never synthesizes OFF/OFF defaults. +- Notification values need not initially be OFF. Preparation snapshots the real values before its notification PATCH. It sets only the two UI-owned types to OFF and verifies that readback. Additional server notification type names and values remain intact. + +After `pm clear`, Firebase token creation is asynchronous. `MaestroProfileNotificationReadiness` supplies the registration barrier inside authenticated feature preparation: + +1. Wait at most 60 seconds for a nonblank `FcmRepository.getSavedToken()`, checking every 250 milliseconds. Firebase's existing startup initialization and `FcmService.onNewToken` save the real token in encrypted preferences. The fixture does not create or write tokens. +2. Call the real `FcmRepository.registerToken()` once, with a 30-second limit. This uses the existing authenticated `POST /login` and `X-Fcm-Token` header. Only `BaseResult.Success` permits continuation; an error, exception, or timeout fails preparation. +3. Require the saved token to remain unchanged. Read actual settings with a 30-second limit, validate the required rows, and check the token again. +4. Save the original settings and token in the private recovery snapshot before the first feature PATCH. No profile or preference mutation can occur before preparation returns. + +Only absent or blank tokens are polled. Storage failures, registration failures, invalid rows, and token rotations are not retried. Fixed errors include `profile_fcm_token_timeout`, `profile_fcm_storage_failed`, `profile_fcm_registration_failed`, `profile_fcm_registration_timeout`, `profile_fcm_settings_timeout`, and `profile_fcm_identity_changed`. No error includes a token or backend exception body. The E2E application does not install the normal debug Timber tree. + +The fixture does not wait for `MainViewModel` or the normal registration worker. Firebase initialization occurs at application startup; the manifest retains the real messaging service and does not disable auto-initialization. No new Firebase API or manual persistence path is used. The other three profile flows never open notification settings and do not require, read, register, or restore notification state in new runs. + +CI still needs valid `GOOGLE_SERVICES_JSON_DEV_BASE64` for `com.project200.undabang`, working Google Play services on the selected API34 `google_apis` emulator, network access to Firebase and DEV APIs, and valid dedicated-account authentication. Source inspection cannot establish these external conditions. If Firebase cannot supply a token within the limit, preparation fails before feature mutation. Initial device registration can create real server device settings; their returned values, not assumed defaults, become the notification baseline. This suite does not redesign authentication or remove backend device-registration records. + +The host serializes the dedicated account. Run IDs are the shared 16-character ASCII hex `E2E_RUN_ID`. Generated nickname/bio values and the chosen catalog ID are internal nonsecret Maestro variables. Credentials and the actual FCM token never become Maestro command-line variables. + +## Failure recovery + +The E2E-only Hilt map contributes `MaestroProfileFixture` under `profile`. It consumes the shared `MaestroFeatureRequest` and returns only safe string variables. Before its first feature write, it atomically stores `files/maestro-profile-recovery.json` in app-private storage. The record contains member/run/flow identity, original nickname/gender/bio, and exact preference IDs/type IDs/skills/days. The notification flow also stores every raw notification type/value and the original FCM device identity. New non-notification snapshots store explicit JSON nulls for both notification fields, not invented settings or tokens. The record contains no raw authentication state. + +The common `finally` calls feature cleanup **before** session cleanup or app-data clear, including when preparation or a UI assertion fails. Cleanup attempts profile, preference, and each recorded notification recovery independently. It uses real PUT/PATCH/DELETE calls and successful GET readbacks: + +1. Restore the original profile tuple. Writes outside the scenario's owned nickname/bio values fail rather than overwrite unrelated data. Validation-flow writes and unintended gender changes remain failures even if recovery succeeds. +2. Delete only the record for the run's previously absent catalog type. Restore altered original values with PATCH while keeping their original IDs. Missing/replaced IDs and unrelated additions fail; the fixture never wipes or recreates baseline preferences. Unintended original-record changes remain test failures after restoration. +3. For a recorded notification snapshot, restore values on the **same saved FCM token**, preserving raw unknown type names. Changed device identity, missing rows, unrelated unknown-type changes, unsuccessful writes, or mismatched readbacks fail. Existing snapshots with notification data retain this exact recovery even for another flow. Only explicitly absent notification data in a non-notification flow skips this area; missing data for `profile-notification` fails. + +The fixture keeps its snapshot after successful feature restoration. The host's final successful `pm clear` removes it only after feature restoration, exercise cleanup, and session clear succeed. If a later shared cleanup step fails, the snapshot remains available for a retry with the exact same member/run/flow identity. Repeated restoration reads the original profile, preference IDs, and notification values without rewriting an already-restored baseline or registering another FCM identity. Any restoration failure still fails the flow and preserves recovery metadata. The shared recovery marker prevents app-data clear and the next flow from destroying that metadata. Do not upload this file, manually clear app data, or substitute a new device token to conceal a failed restoration. Force-stopping the app before bootstrap prevents live UI coroutines from issuing later local writes; remote server commit timing still requires real CI observation. + +Android permission changes use a disposable emulator policy: the flow grants the permission again on its normal path, and the shared runner clears the disposable installation after successful backend cleanup. Reusable devices are not supported by this suite's permission-reset policy. No server setting is inferred from the Android permission state. + +## Observable UI state and tests + +Profile fields put their stable input IDs on `UndabangTextField.inputModifier`, not its layout wrapper. `profile_edit_loaded` appears only after a successful profile read. The existing introduction section wrapper additionally exposes `profile_edit_introduction_empty` or `profile_edit_introduction_present` from the actual input string, without spoken content or a replacement field ID. The flow checks that state only after the loaded barrier. Preference screens expose loaded TYPE/DETAIL states, use exercise-type-scoped chip/day/skill IDs, and scroll to each target explicitly. The empty-selection check also reaches originally selected chips outside the viewport. Notification switches are hidden on read errors, disabled while saving, and exposed as `notification_ready` only after required rows and PATCH readback succeed. Permission denial has a persistent OS-derived explanation as well as the existing toast. + +Both edit flows use `profile-clear-text.yaml` for all eight nickname/introduction edits. The helper long-presses the actual field, selects Korean `모두 선택` when available, and erases the selection. A center tap followed by `eraseText` can leave text after the cursor and is not sufficient. Before every duplicate check or save attempt, `profile-assert-inputs.yaml` verifies the exact expected nickname and introduction in the actual inputs. It uses `copyTextFrom` plus strict equality for the nickname and nonempty introduction. For an empty introduction, it checks `profile_edit_introduction_empty` instead of copying hint text. Verification never overwrites `output.originalNickname` or `output.originalIntroduction`. A failed input guard stops before that action; the previously persisted baseline or exact test tuple remains within the unchanged recovery allowlist. + +Behavioral coverage is in profile ViewModel tests and `app/src/testE2e/.../MaestroProfilePolicyTest.kt`, `MaestroProfileRecoveryTest.kt`, and `MaestroProfileNotificationReadinessTest.kt`. The empty-bio recovery regression validates the baseline before checking the exact empty-string PUT and GET readback. Separate policy coverage rejects null baselines. The ViewModel regression loads an existing introduction, clears and saves an empty string, then reloads empty input over an unsaved introduction. Recovery rejects null, whitespace-only, and changed nonempty readbacks. The readiness tests cover delayed callback storage, the timeout boundary, absent/blank tokens, backend errors/timeouts, token rotation, missing rows, cancellation, and notification-only scope. The recovery tests retain original-ID preservation, exact owned deletion, raw additional notification types, backend read errors, wrong device identity, failed readback, explicit notification absence, cross-flow recovery for existing notification snapshots, and repeat-restoration ownership checks. These are unit-test doubles only; the actual fixture and all four Maestro scenarios call the real backend. + +[Maestro CI run 39027346415](https://github.com/projects200/android/actions/runs/39027346415), at `719619ab`, passed `profile-edit`, `profile-edit-validation`, and all 12 common flows. [Normal CI run 39027346334](https://github.com/projects200/android/actions/runs/39027346334) also succeeded. Only the preference and notification flows failed. + +The preference failure hierarchy showed the selected chip as `checked: true`, `selected: false`. Type, day, and skill controls share Compose `selected` semantics; the flow now checks their exported Android `checked` state without removing positive or negative assertions. The notification flow persisted ON and read it back, then stopped at Android's notification permission prompt after relaunch. The denial helper now waits for that prompt or app readiness, explicitly taps the actual deny button when present, and still requires app readiness and the caller's denied-state checks. The latest startup failure did not exercise either correction; both still require successful runtime verification. + +At `4ca316ad`, [Normal CI run 39029919843](https://github.com/projects200/android/actions/runs/39029919843) succeeded, but [Maestro CI run 39029919892](https://github.com/projects200/android/actions/runs/39029919892) failed all 16 flows during startup. The inspected `smoke-tabs` and `login-screen` hierarchies both show a System UI ANR dialog. No preference or notification feature step ran. The ANR trigger remains unknown; the diagnostics below are instrumentation, not a root-cause fix. + +After a nonzero Maestro exit, the runner reads `dumpsys activity lastanr` once and writes only `/last-anr-summary.json`: fixed process categories (`system-ui`, `application`, `other`, `unknown`), fixed reason categories (`input-dispatch`, `broadcast`, `service`, `content-provider`, `unknown`), and numeric load averages/total CPU or null. The last ANR may describe an earlier event; it does not prove the current failure's cause. Raw reports, process names, PIDs, window titles, Intent contents, and backend strings are not published; raw logcat remains uncollected. Expected collection or write failures preserve the original `maestro-flow-failed` result and the existing redacted console; cancellation still propagates. The runner does not retry or dismiss the ANR. + +The earlier [run 39022487235](https://github.com/projects200/android/actions/runs/39022487235) passed the empty-bio baseline checks but left run-ID suffixes during UI restoration. Exact recovery ownership validation refused that unexpected tuple. Recovery snapshots are emulator-local, absent from uploaded artifacts, and unavailable on the completed runner. Run `39027346415` used the leftover values as its baseline; its successful edit roundtrip does not prove restoration of the earlier account values. The current starting snapshot remains contaminated, and the earlier server values still need separate verified restoration. No local build, test, lint, formatter, syntax-check command, adb, emulator, Firebase, or Maestro command was run for these changes. diff --git a/.maestro/README.md b/.maestro/README.md index 52fa0191..54d3d66a 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -251,8 +251,10 @@ peer/matching 비밀은 선택된 descriptor가 요구할 때만 필수입니다 - `report.xml`: 실제 Maestro 종료 코드/JUnit과 준비·정리 결과를 합친 플로우별 JUnit. 빈 실행/skip/리포트 부재는 실패입니다. - `/console.log`, `debug-*.log`, `debug-*.json`: 인증 값/JWT/인증 관련 줄을 제거한 Maestro 출력과 debug 정보. 원본 subprocess 예외/traceback을 출력하지 않습니다. - `/screenshot-*.png`: **Maestro 실행 중** 생성된 PNG만 보관합니다. 준비/내보내기 화면은 캡처하지 않습니다. 원본 텍스트에서 민감 정보가 감지되면 이미지도 보관하지 않습니다. +- `/device-log.log`: Maestro 비정상 종료 시 logcat 최근 500줄 중 `[MEASURE]`/`WebSocket`/`Connectivity`/`AndroidRuntime`/`FATAL EXCEPTION`/`OkHttp`가 있는 줄만 선별하고 인증 값/JWT/인증 관련 줄 제거 규칙을 적용한 로그입니다. +- `/last-anr-summary.json`: 이어서 조회한 마지막 ANR의 고정 process/reason 분류와 load average/전체 CPU 수치만 보관합니다. 이전 사건일 수 있으며 원문·PID·창 제목·Intent는 공개하지 않습니다. -Maestro의 HOME/Java user.home은 실행별 임시 디렉터리로 격리하며 analytics, 업데이트 확인, AI 알림을 끕니다. 원본 CLI 임시 출력은 finally에서 제거하고 업로드하지 않습니다. authState는 호스트 메모리/앱 private stdin 경로에만 전달됩니다. 앱은 e2e에서 자격 증명을 포함할 수 있는 Timber 로깅을 사용하지 않습니다. raw logcat을 수집하지 않습니다. +Maestro의 HOME/Java user.home은 실행별 임시 디렉터리로 격리하며 analytics, 업데이트 확인, AI 알림을 끕니다. 원본 CLI 임시 출력은 finally에서 제거하고 업로드하지 않습니다. authState는 호스트 메모리/앱 private stdin 경로에만 전달됩니다. 앱은 e2e에서 자격 증명을 포함할 수 있는 Timber 로깅을 사용하지 않습니다. raw logcat과 dumpsys 원문은 파일로 저장하거나 업로드하지 않습니다. CI는 실패해도 `always()`로 상태 파일과 안전한 XML/log/JSON/PNG만 7일 보관합니다. `workflow-status.log`만 있으면 빌드/설정/에뮬레이터 단계에서 UI 실행 전 실패한 상태일 수 있습니다. 이 파일은 테스트 통과 증거가 아닙니다. stdout의 고정 실패 코드와 JUnit을 먼저 보고 안전한 hierarchy/스크린샷을 함께 확인하세요. diff --git a/.maestro/flows/profile-edit-validation.yaml b/.maestro/flows/profile-edit-validation.yaml new file mode 100644 index 00000000..5b764ff6 --- /dev/null +++ b/.maestro/flows/profile-edit-validation.yaml @@ -0,0 +1,116 @@ +appId: com.project200.undabang +name: 프로필 편집 - 저장 방지 경로 +tags: + - regression + - requires-login +--- +# 세 가지 차단 경로 모두 저장하지 않아야 합니다. finally가 실제 원본과 대조하고 복구합니다. +- runFlow: ../subflows/launch-logged-in.yaml + +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 + +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_loaded + timeout: 30000 + +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} +- evalScript: ${output.nickA = 'e2ea' + E2E_RUN_ID} +- evalScript: ${output.nickB = 'e2eb' + E2E_RUN_ID} + +# 1) 중복 확인 없이 완료 -> 저장이 막힙니다 +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: ${output.nickA} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickA} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 2) 유효하지 않은 값 -> 규칙 위반 문구가 뜨고, 중복 확인 API는 호출되지 않습니다 +# ValidateNicknameUseCase 정규식(한글/영문/숫자만)에 맞춰 '!'로 위반시킵니다 +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: "e2e615!invalid" +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: e2e615!invalid + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} +- tapOn: "중복 확인" +- assertVisible: "닉네임 조건을 확인해주세요." +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: e2e615!invalid + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 3) 닉네임 A로 중복 확인을 통과한 뒤 B로 다시 바꾸면 확인 상태가 풀립니다(요구 완료 기준) +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: ${output.nickA} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickA} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 + +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: ${output.nickB} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.nickB} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_ORIGINAL_BIO} +# updateNickname이 값이 바뀔 때마다 isNicknameChecked를 초기화합니다. 중복 확인 문구가 사라졌는지 확인합니다 +- assertNotVisible: "사용 가능한 닉네임입니다." +- tapOn: "완료" +- extendedWaitUntil: + visible: "닉네임 중복 확인을 해주세요." + timeout: 10000 + +# 세 경로 모두 저장되지 않았으므로 로컬 입력만 버리고 나갑니다 +- back +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} + timeout: 15000 diff --git a/.maestro/flows/profile-edit.yaml b/.maestro/flows/profile-edit.yaml new file mode 100644 index 00000000..7e0dd965 --- /dev/null +++ b/.maestro/flows/profile-edit.yaml @@ -0,0 +1,220 @@ +appId: com.project200.undabang +name: 프로필 편집 - 닉네임·소개 변경과 복구 +tags: + - regression + - requires-login +--- +# 준비 API가 읽은 원문과 로드된 입력 노드가 일치한 뒤에만 수정합니다. +# 실패 복구는 공통 finally의 profile fixture가 실제 API로 수행합니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_ORIGINAL_NICKNAME} +- copyTextFrom: + id: profile_edit_nickname_field +- evalScript: ${output.originalNickname = maestro.copiedText} +- assertTrue: ${output.originalNickname === E2E_PROFILE_ORIGINAL_NICKNAME} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +# 빈 입력은 hint를 복사하지 않고, 로드된 실제 값의 상태를 확인한 뒤 API 원문을 보관합니다. +- assertVisible: + id: "${E2E_PROFILE_ORIGINAL_BIO === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- evalScript: ${output.originalIntroduction = E2E_PROFILE_ORIGINAL_BIO} +- runFlow: + when: + true: ${E2E_PROFILE_ORIGINAL_BIO !== ''} + commands: + - assertVisible: + id: profile_edit_introduction_field + text: ${E2E_PROFILE_ORIGINAL_BIO} + - copyTextFrom: + id: profile_edit_introduction_field + - evalScript: ${output.originalIntroduction = maestro.copiedText} + - assertTrue: ${output.originalIntroduction === E2E_PROFILE_ORIGINAL_BIO} +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: ${E2E_PROFILE_NEW_NICKNAME} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${E2E_PROFILE_NEW_NICKNAME} + EXPECTED_INTRODUCTION: ${output.originalIntroduction} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_introduction_field +- inputText: ${E2E_PROFILE_NEW_BIO} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${E2E_PROFILE_NEW_NICKNAME} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_NEW_BIO} +- tapOn: "완료" +- extendedWaitUntil: + visible: "프로필 수정이 완료됐습니다." + timeout: 15000 +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${E2E_PROFILE_NEW_NICKNAME} + timeout: 15000 +- scrollUntilVisible: + element: + id: introduction_tv + text: ${E2E_PROFILE_NEW_BIO} + direction: DOWN +- assertVisible: + id: introduction_tv + text: ${E2E_PROFILE_NEW_BIO} +- scrollUntilVisible: + element: + id: mypage_edit_btn + direction: UP +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${E2E_PROFILE_NEW_NICKNAME} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- assertVisible: + id: profile_edit_introduction_field + text: ${E2E_PROFILE_NEW_BIO} +# UI 복구도 저장·재조회 경로를 반드시 통과합니다. API finally 복구의 대체물이 아닙니다. +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_nickname_field +- inputText: ${output.originalNickname} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.originalNickname} + EXPECTED_INTRODUCTION: ${E2E_PROFILE_NEW_BIO} +- tapOn: "중복 확인" +- extendedWaitUntil: + visible: "사용 가능한 닉네임입니다." + timeout: 15000 +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- runFlow: + file: ../subflows/profile-clear-text.yaml + env: + FIELD_ID: profile_edit_introduction_field +# 빈 원문은 실제 입력을 지운 상태로 저장하며 inputText에 빈 문자열을 넘기지 않습니다. +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - inputText: ${output.originalIntroduction} +- hideKeyboard +- runFlow: + file: ../subflows/profile-assert-inputs.yaml + env: + EXPECTED_NICKNAME: ${output.originalNickname} + EXPECTED_INTRODUCTION: ${output.originalIntroduction} +- tapOn: "완료" +- extendedWaitUntil: + visible: "프로필 수정이 완료됐습니다." + timeout: 15000 +- extendedWaitUntil: + visible: + id: nickname_tv + text: ${output.originalNickname} + timeout: 15000 +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - scrollUntilVisible: + element: + id: introduction_tv + text: ${output.originalIntroduction} + direction: DOWN + - assertVisible: + id: introduction_tv + text: ${output.originalIntroduction} +# 저장 뒤 다시 편집기를 열어 실제 서버 재조회가 끝난 입력 상태로 빈 원문까지 검증합니다. +- scrollUntilVisible: + element: + id: mypage_edit_btn + direction: UP +- tapOn: + id: mypage_edit_btn +- extendedWaitUntil: + visible: + id: profile_edit_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: DOWN +- assertVisible: + id: profile_edit_nickname_field + text: ${output.originalNickname} +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +- assertVisible: + id: "${output.originalIntroduction === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- runFlow: + when: + true: ${output.originalIntroduction !== ''} + commands: + - assertVisible: + id: profile_edit_introduction_field + text: ${output.originalIntroduction} + - copyTextFrom: + id: profile_edit_introduction_field + - assertTrue: ${maestro.copiedText === output.originalIntroduction} diff --git a/.maestro/flows/profile-notification.yaml b/.maestro/flows/profile-notification.yaml new file mode 100644 index 00000000..7700c951 --- /dev/null +++ b/.maestro/flows/profile-notification.yaml @@ -0,0 +1,113 @@ +appId: com.project200.undabang +name: 알림 설정 - 토글·권한 거부 구분과 복구 +tags: + - regression + - requires-login +--- +# fixture가 실제 기기 설정 원본을 저장하고 두 known type만 OFF로 준비합니다. +# finally는 원래 ON/OFF와 알 수 없는 서버 type까지 같은 FCM 기기에 복구합니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" +- runFlow: ../subflows/profile-notification-ready.yaml +- assertNotVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: false +- assertVisible: + id: chatting_noti_switch + checked: false +# 각 PATCH와 GET 완료를 기다려 전체 상태를 포함한 동시 쓰기를 막습니다. +- tapOn: + id: exercise_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: true +- tapOn: + id: chatting_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: chatting_noti_switch + checked: true +- back +- tapOn: "알림" +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: true +- assertVisible: + id: chatting_noti_switch + checked: true +# apiLevel34 descriptor에서 실제 POST_NOTIFICATIONS 권한을 거부합니다. +- runFlow: ../subflows/profile-notification-denied.yaml +- tapOn: + id: profile_nav_graph +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: false +- assertVisible: + id: chatting_noti_switch + checked: false +- assertVisible: "알림을 받으려면 앱 설정에서 권한을 허용해주세요." +- back +- assertVisible: "설정" +# 권한만 허용하고 서버를 다시 읽어 ON이 유지됨을 확인합니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: profile_nav_graph +- tapOn: + id: setting_btn +- extendedWaitUntil: + visible: "설정" + timeout: 30000 +- tapOn: "알림" +- runFlow: ../subflows/profile-notification-ready.yaml +- assertNotVisible: + id: notification_permission_denied +- assertVisible: + id: exercise_noti_switch + checked: true +- assertVisible: + id: chatting_noti_switch + checked: true +- tapOn: + id: exercise_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: false +- tapOn: + id: chatting_noti_switch +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: chatting_noti_switch + checked: false +- back +- tapOn: "알림" +- runFlow: ../subflows/profile-notification-ready.yaml +- assertVisible: + id: exercise_noti_switch + checked: false +- assertVisible: + id: chatting_noti_switch + checked: false diff --git a/.maestro/flows/profile-preferred-exercise.yaml b/.maestro/flows/profile-preferred-exercise.yaml new file mode 100644 index 00000000..ea52ba6a --- /dev/null +++ b/.maestro/flows/profile-preferred-exercise.yaml @@ -0,0 +1,170 @@ +appId: com.project200.undabang +name: 선호 운동 설정 - 추가·변경·검증과 복구 +tags: + - regression + - requires-login +--- +# API가 원본 ID를 보관하고 실제 미선택 catalog type 하나만 이번 실행에 배정합니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: profile_nav_graph +- extendedWaitUntil: + visible: "최근 30일 운동 횟수" + timeout: 30000 +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + checked: false +- tapOn: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + checked: true +- runFlow: ../subflows/profile-preference-detail.yaml +# 요일과 숙련도 누락은 모두 실제 저장 검증에 막혀야 합니다. +- tapOn: "완료" +- extendedWaitUntil: + visible: "요일과 숙련도를 모두 선택해주세요" + timeout: 10000 +- tapOn: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 +- tapOn: "완료" +- extendedWaitUntil: + visible: "요일과 숙련도를 모두 선택해주세요" + timeout: 10000 +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN +- tapOn: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 +- scrollUntilVisible: + element: + text: ${E2E_PROFILE_ADDED_NAME} + direction: DOWN + timeout: 15000 +- assertVisible: ${E2E_PROFILE_ADDED_NAME} +# 첫 저장의 실제 재조회 결과입니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + checked: true +- runFlow: ../subflows/profile-preference-detail.yaml +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + checked: true +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + checked: false +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + checked: true +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + direction: UP +- tapOn: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 +# 월+수 변경을 재조회로 확인한 뒤 월요일만 해제합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- runFlow: ../subflows/profile-preference-detail.yaml +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + checked: true +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + checked: true +- tapOn: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 +# 수요일만 남긴 값도 실제 재조회 뒤 확인합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- runFlow: ../subflows/profile-preference-detail.yaml +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + checked: false +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_2 + checked: true +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + direction: DOWN +- assertVisible: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_skill_BEGINNER + checked: true +- back +- back +# 화면 밖의 선택도 ID별로 스크롤해서 모두 해제합니다. 이 변경은 저장하지 않습니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- tapOn: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} +- evalScript: ${output.baselineTypeIds = E2E_PROFILE_BASELINE_TYPE_IDS.split(','); output.typeIndex = 0} +- repeat: + times: ${output.baselineTypeIds.length} + commands: + - scrollUntilVisible: + element: + id: preferred_exercise_type_start + direction: UP + - scrollUntilVisible: + element: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} + direction: DOWN + - assertVisible: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} + checked: true + - tapOn: + id: preferred_exercise_type_${output.baselineTypeIds[output.typeIndex]} + - evalScript: ${output.typeIndex = output.typeIndex + 1} +- tapOn: "다음" +- tapOn: "완료" +- extendedWaitUntil: + visible: "최소 하나의 운동을 선택해주세요" + timeout: 10000 +- back +- back +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP +- assertVisible: "최근 30일 운동 횟수" +# 이번 실행이 추가한 type만 해제합니다. 기존 레코드를 삭제·재생성하지 않습니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + checked: true +- tapOn: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} +- tapOn: "다음" +- tapOn: "완료" +- extendedWaitUntil: + visible: "선호 운동이 저장되었습니다" + timeout: 15000 +- assertNotVisible: ${E2E_PROFILE_ADDED_NAME} +# 최종 부재는 offscreen 판정이 아니라 로드된 type 선택 상태와 API finally 대조로 확인합니다. +- runFlow: ../subflows/profile-open-preferences.yaml +- assertVisible: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + checked: false +- back +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP +- assertVisible: "최근 30일 운동 횟수" diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 2918d83b..67915df4 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -52,6 +52,33 @@ "MAESTRO_MATCHING_SEARCH_QUERY", "MAESTRO_MATCHING_CATALOG_PLACE_NAME", "MAESTRO_MATCHING_ROOM_ID", }) +PROFILE_PREPARE_FAILURES = { + "profile_recovery_pending": "bootstrap-prepare-failed-profile-recovery-pending", + "profile_fixture_read_failed": "bootstrap-prepare-failed-profile-fixture-read-failed", + "profile_baseline_nickname_unsupported": "bootstrap-prepare-failed-profile-baseline-nickname-unsupported", + "profile_baseline_gender_unsupported": "bootstrap-prepare-failed-profile-baseline-gender-unsupported", + "profile_baseline_bio_null": "bootstrap-prepare-failed-profile-baseline-bio-null", + "profile_baseline_bio_too_long": "bootstrap-prepare-failed-profile-baseline-bio-too-long", + "profile_baseline_bio_control_characters": + "bootstrap-prepare-failed-profile-baseline-bio-control-characters", + "profile_preferences_invalid": "bootstrap-prepare-failed-profile-preferences-invalid", + "profile_preference_baseline_unavailable": + "bootstrap-prepare-failed-profile-preference-baseline-unavailable", + "profile_catalog_invalid": "bootstrap-prepare-failed-profile-catalog-invalid", + "profile_unselected_exercise_missing": "bootstrap-prepare-failed-profile-unselected-exercise-missing", + "profile_fcm_token_timeout": "bootstrap-prepare-failed-profile-fcm-token-timeout", + "profile_fcm_registration_failed": "bootstrap-prepare-failed-profile-fcm-registration-failed", + "profile_fcm_registration_timeout": "bootstrap-prepare-failed-profile-fcm-registration-timeout", + "profile_fcm_settings_timeout": "bootstrap-prepare-failed-profile-fcm-settings-timeout", + "profile_notifications_invalid": "bootstrap-prepare-failed-profile-notifications-invalid", + "profile_fcm_storage_failed": "bootstrap-prepare-failed-profile-fcm-storage-failed", + "profile_fcm_identity_changed": "bootstrap-prepare-failed-profile-fcm-identity-changed", + "profile_snapshot_write_failed": "bootstrap-prepare-failed-profile-snapshot-write-failed", + "profile_fixture_write_failed": "bootstrap-prepare-failed-profile-fixture-write-failed", + "profile_notification_prepare_failed": "bootstrap-prepare-failed-profile-notification-prepare-failed", + "profile_request_invalid": "bootstrap-prepare-failed-profile-request-invalid", +} + MATCHING_PREPARE_FAILURES = { "matching_recovery_required": "bootstrap-prepare-failed-matching-recovery-required", "matching_peer_must_differ": "bootstrap-prepare-failed-matching-peer-must-differ", @@ -758,11 +785,14 @@ def bootstrap(self, operation, credentials=None, timeout=300, feature=None, flow except (ValueError, TypeError): raise RunnerFailure("invalid-bootstrap-result") from None if not isinstance(result, dict) or result.get("status") != expected: - # 앱의 reason도 외부 입력이므로 검토된 매칭 준비 코드만 고정 오류로 공개합니다. + # 앱의 reason도 외부 입력이므로 해당 기능에서 검토된 준비 코드만 고정 오류로 공개합니다. failure = "bootstrap-" + operation + "-failed" - if (operation == "prepare" and feature == "matching" and isinstance(result, dict) + if (operation == "prepare" and isinstance(result, dict) and result.get("status") == "failed" and isinstance(result.get("reason"), str)): - failure = MATCHING_PREPARE_FAILURES.get(result["reason"], failure) + if feature == "profile": + failure = PROFILE_PREPARE_FAILURES.get(result["reason"], failure) + elif feature == "matching": + failure = MATCHING_PREPARE_FAILURES.get(result["reason"], failure) raise RunnerFailure(failure) variables = safe_variables(result.get("variables", {})) self.remove_private() diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index d7acf5f1..da1ba622 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -288,6 +288,92 @@ def test_wrong_status_and_non_object_results_fail_closed(self): self.assertEqual(self.clock.sleeps, []) self.assert_no_credential_arguments() + def test_profile_prepare_discloses_only_fixed_reviewed_failures(self): + self.transport.pending_sleeps = 0 + cases = ( + ("profile_baseline_nickname_unsupported", + "bootstrap-prepare-failed-profile-baseline-nickname-unsupported"), + ("profile_baseline_gender_unsupported", + "bootstrap-prepare-failed-profile-baseline-gender-unsupported"), + ("profile_baseline_bio_null", "bootstrap-prepare-failed-profile-baseline-bio-null"), + ("profile_baseline_bio_too_long", "bootstrap-prepare-failed-profile-baseline-bio-too-long"), + ("profile_baseline_bio_control_characters", + "bootstrap-prepare-failed-profile-baseline-bio-control-characters"), + ("profile_fcm_token_timeout", "bootstrap-prepare-failed-profile-fcm-token-timeout"), + ) + for reason, expected in cases: + with self.subTest(reason=reason): + self.transport.result = json.dumps({ + "status": "failed", "reason": reason, "private": SYNTHETIC_SECRET, + }).encode("utf-8") + self.assert_failure( + expected, + lambda: self.device.bootstrap( + "prepare", CREDENTIALS, feature="profile", flow="profile-edit", + run_id="0123456789abcdef", parameters={}, + ), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + + def test_profile_prepare_unknown_and_non_string_reasons_remain_generic(self): + self.transport.pending_sleeps = 0 + reasons = ( + "profile_unknown_failure", + "profile_baseline_text_unsupported", + "profile_baseline_nickname_unsupported-" + SYNTHETIC_SECRET, + SYNTHETIC_SECRET, + {"code": "profile_baseline_bio_null", "private": SYNTHETIC_SECRET}, + ["profile_baseline_bio_control_characters", SYNTHETIC_SECRET], + None, + 7, + True, + ) + for index, reason in enumerate(reasons): + with self.subTest(case=index): + self.transport.result = json.dumps({"status": "failed", "reason": reason}).encode("utf-8") + self.assert_failure( + "bootstrap-prepare-failed", + lambda: self.device.bootstrap( + "prepare", CREDENTIALS, feature="profile", flow="profile-edit", + run_id="0123456789abcdef", parameters={}, + ), + ) + self.assertTrue((self.transport.root / runner.RESULT).exists()) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + + def test_reviewed_profile_reason_outside_failed_profile_prepare_remains_generic(self): + self.transport.pending_sleeps = 0 + cases = ( + ("prepare", "profile", "exported", "bootstrap-prepare-failed"), + ("prepare", "profile", None, "bootstrap-prepare-failed"), + ("prepare", "matching", "failed", "bootstrap-prepare-failed"), + ("cleanup", "profile", "failed", "bootstrap-cleanup-failed"), + ("prepare", None, "failed", "bootstrap-prepare-failed"), + ) + for reason in ("profile_baseline_bio_null", "profile_fcm_token_timeout"): + for operation, feature, status, expected in cases: + with self.subTest(reason=reason, operation=operation, feature=feature, status=status): + payload = json.dumps({ + "status": status, "reason": reason, "private": SYNTHETIC_SECRET, + }).encode("utf-8") + self.transport.result = payload + metadata = {} + if feature is not None: + metadata = { + "feature": feature, + "flow": "profile-preferred-exercise" if feature == "profile" else "matching-place-crud", + "run_id": "0123456789abcdef", "parameters": {}, + } + self.assert_failure( + expected, lambda: self.device.bootstrap(operation, CREDENTIALS, **metadata), + ) + self.assertEqual((self.transport.root / runner.RESULT).read_bytes(), payload) + self.assertEqual(self.clock.sleeps, []) + self.assert_no_credential_arguments() + def test_matching_prepare_failure_reason_is_exposed_as_fixed_code(self): self.transport.pending_sleeps = 0 cases = ( @@ -322,9 +408,11 @@ def test_matching_failure_reason_is_not_exposed_outside_prepare(self): self.transport.pending_sleeps = 0 metadata = {"flow": "matching-place-crud", "run_id": "0123456789abcdef", "parameters": {}} matching = {"feature": "matching", **metadata} + profile = {"feature": "profile", **metadata, "flow": "profile-preferred-exercise"} cases = ( ("matching-cleanup", "cleanup", matching, "failed"), ("other-feature", "prepare", {"feature": "exercise", **metadata}, "failed"), + ("profile-prepare", "prepare", profile, "failed"), ("absent-feature", "prepare", {}, "failed"), ("wrong-status", "prepare", matching, "exported"), ("absent-status", "prepare", matching, None), @@ -658,15 +746,32 @@ def test_system_ui_anr_summary_keeps_only_categories_and_load(self): "load_average": [14.67, 12.04, 6.64], "total_cpu_percent": 98.5, }, ) - self.assertEqual( - self.device.call.call_args, - mock.call("shell", "dumpsys", "activity", "lastanr", timeout=30), - ) for path in self.destination.iterdir(): content = path.read_text(encoding="utf-8") - for value in (private, self.escaped_secret, SYNTHETIC_SECRET): + for value in ( + private, self.escaped_secret, SYNTHETIC_SECRET, "com.android.systemui", "6175", + ): self.assertNotIn(value, content) + def test_application_anr_summary_uses_fixed_reason_categories(self): + for reason, category in ( + ("Input dispatching timed out (synthetic-private-window)", "input-dispatch"), + ("executing service synthetic.private.service", "service"), + ("ContentProvider not responding: synthetic.private.provider", "content-provider"), + ): + with self.subTest(category=category): + self.device.call.return_value = ( + 0, ("ANR in " + runner.APP_ID + "\nReason: " + reason + "\n").encode("utf-8"), b"", + ) + runner.save_failure_anr(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + { + "process": "application", "reason": category, + "load_average": None, "total_cpu_percent": None, + }, + ) + def test_anr_summary_does_not_publish_other_process_or_reason_text(self): self.device.call.return_value = ( 0, b"ANR in synthetic.private.process\nReason: private-user-content\n", b"", @@ -685,20 +790,48 @@ def test_missing_anr_report_is_unknown_not_an_application_failure(self): {"process": "unknown", "reason": "unknown", "load_average": None, "total_cpu_percent": None}, ) - def test_anr_capture_failure_preserves_original_failure_and_console(self): - self.device.call.side_effect = [(0, b"", b""), runner.RunnerFailure("tool-timeout")] - with self.assertRaises(runner.RunnerFailure) as caught: - runner.run_maestro( - self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, - ) + def test_unavailable_anr_preserves_original_failure_and_console(self): + self.device.call.side_effect = [ + (0, b"", b""), + (1, SYNTHETIC_SECRET.encode("utf-8"), self.escaped_secret.encode("utf-8")), + ] + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) self.assertEqual(str(caught.exception), "maestro-flow-failed") - self.assertTrue((self.destination / "console.log").is_file()) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") self.assertFalse((self.destination / "last-anr-summary.json").exists()) + def test_anr_capture_failure_preserves_original_failure_and_console(self): + for failure in (runner.RunnerFailure("tool-timeout"), OSError(SYNTHETIC_SECRET)): + with self.subTest(failure=type(failure).__name__): + self.device.call.side_effect = [(0, b"", b""), failure] + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + def test_anr_write_failure_preserves_original_failure_without_private_error(self): - self.device.call.return_value = ( - 0, b"ANR in com.project200.undabang\nReason: Input dispatching timed out\n", b"", - ) + self.device.call.side_effect = [ + (0, b"", b""), + (0, b"ANR in com.project200.undabang\nReason: Input dispatching timed out\n", b""), + ] write_text = Path.write_text def fail_anr_write(path, *args, **kwargs): @@ -729,7 +862,11 @@ def test_anr_interruption_propagates_unchanged(self): self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, ) self.assertIs(caught.exception, interruption) - self.assertTrue((self.destination / "console.log").is_file()) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertFalse((self.destination / "last-anr-summary.json").exists()) class EmulatorInputTests(unittest.TestCase): diff --git a/.maestro/subflows/profile-assert-inputs.yaml b/.maestro/subflows/profile-assert-inputs.yaml new file mode 100644 index 00000000..c0d11969 --- /dev/null +++ b/.maestro/subflows/profile-assert-inputs.yaml @@ -0,0 +1,24 @@ +appId: com.project200.undabang +--- +# 중복 확인·저장 직전에 실제 입력을 대조합니다. 원문 보관용 output 값은 덮어쓰지 않습니다. +- scrollUntilVisible: + element: + id: profile_edit_introduction_field + direction: DOWN +# 빈 소개는 hint를 복사하지 않고 실제 입력의 빈 상태를 확인합니다. +- assertVisible: + id: "${EXPECTED_INTRODUCTION === '' ? 'profile_edit_introduction_empty' : 'profile_edit_introduction_present'}" +- runFlow: + when: + true: ${EXPECTED_INTRODUCTION !== ''} + commands: + - copyTextFrom: + id: profile_edit_introduction_field + - assertTrue: ${maestro.copiedText === EXPECTED_INTRODUCTION} +- scrollUntilVisible: + element: + id: profile_edit_nickname_field + direction: UP +- copyTextFrom: + id: profile_edit_nickname_field +- assertTrue: ${maestro.copiedText === EXPECTED_NICKNAME} diff --git a/.maestro/subflows/profile-clear-text.yaml b/.maestro/subflows/profile-clear-text.yaml new file mode 100644 index 00000000..a9f8330e --- /dev/null +++ b/.maestro/subflows/profile-clear-text.yaml @@ -0,0 +1,12 @@ +appId: com.project200.undabang +--- +# matching-clear-text와 같은 선택 삭제로 커서 뒤의 기존 닉네임·소개도 모두 지웁니다. +# 한 단어 전체가 이미 선택되면 '모두 선택' 메뉴가 없을 수 있습니다. +- longPressOn: + id: ${FIELD_ID} +- runFlow: + when: + visible: "모두 선택" + commands: + - tapOn: "모두 선택" +- eraseText diff --git a/.maestro/subflows/profile-notification-denied.yaml b/.maestro/subflows/profile-notification-denied.yaml new file mode 100644 index 00000000..6677dad0 --- /dev/null +++ b/.maestro/subflows/profile-notification-denied.yaml @@ -0,0 +1,29 @@ +appId: com.project200.undabang +--- +# API33+에서 POST_NOTIFICATIONS만 실제로 거부합니다. 앱 데이터와 FCM identity는 유지합니다. +- stopApp +- launchApp: + clearState: false + permissions: + all: allow + notifications: deny + location: allow + +# 재요청된 OS 권한 창은 실제로 거부하고, 이미 거부된 상태면 앱 진입을 기다립니다. +- extendedWaitUntil: + visible: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button|matching_nav_graph" + timeout: 60000 +- runFlow: + when: + visible: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button" + commands: + - tapOn: + id: "com\\.android\\.permissioncontroller:id/permission_deny_button" + +# 재기동 뒤 실제 OS 권한은 NotificationFragment가 확인하고 화면에 표시합니다. +- extendedWaitUntil: + visible: + id: matching_nav_graph + timeout: 60000 diff --git a/.maestro/subflows/profile-notification-ready.yaml b/.maestro/subflows/profile-notification-ready.yaml new file mode 100644 index 00000000..40893268 --- /dev/null +++ b/.maestro/subflows/profile-notification-ready.yaml @@ -0,0 +1,7 @@ +appId: com.project200.undabang +--- +# ready는 필수 행 조회와 마지막 PATCH의 서버 재확인까지 성공했음을 뜻합니다. +- extendedWaitUntil: + visible: + id: notification_ready + timeout: 30000 diff --git a/.maestro/subflows/profile-open-preferences.yaml b/.maestro/subflows/profile-open-preferences.yaml new file mode 100644 index 00000000..43213873 --- /dev/null +++ b/.maestro/subflows/profile-open-preferences.yaml @@ -0,0 +1,23 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + id: nickname_tv + direction: UP + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_edit_btn + direction: DOWN + timeout: 30000 +- tapOn: + id: preferred_exercise_edit_btn +- extendedWaitUntil: + visible: + id: preferred_exercise_type_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_type_${E2E_PROFILE_ADDED_TYPE_ID} + direction: DOWN + timeout: 30000 diff --git a/.maestro/subflows/profile-preference-detail.yaml b/.maestro/subflows/profile-preference-detail.yaml new file mode 100644 index 00000000..440fefbf --- /dev/null +++ b/.maestro/subflows/profile-preference-detail.yaml @@ -0,0 +1,12 @@ +appId: com.project200.undabang +--- +- tapOn: "다음" +- extendedWaitUntil: + visible: + id: preferred_exercise_detail_loaded + timeout: 30000 +- scrollUntilVisible: + element: + id: preferred_exercise_${E2E_PROFILE_ADDED_TYPE_ID}_day_0 + direction: DOWN + timeout: 30000 diff --git a/.maestro/suites/profile.json b/.maestro/suites/profile.json new file mode 100644 index 00000000..c24b556f --- /dev/null +++ b/.maestro/suites/profile.json @@ -0,0 +1,12 @@ +{ + "flows": [ + "profile-edit", + "profile-edit-validation", + "profile-preferred-exercise", + "profile-notification" + ], + "apiLevel": 34, + "fixture": true, + "requiredEnvironment": [], + "driver": null +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt new file mode 100644 index 00000000..415fb241 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixture.kt @@ -0,0 +1,222 @@ +package com.project200.undabang.e2e + +import android.content.Context +import android.util.AtomicFile +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import javax.inject.Inject + +internal class MaestroProfileFixture + @Inject + constructor( + @ApplicationContext context: Context, + private val api: ApiService, + private val fcmRepository: FcmRepository, + private val recovery: MaestroProfileRecovery, + private val notificationReadiness: MaestroProfileNotificationReadiness, + ) : MaestroFeatureFixture { + private val snapshotFile = AtomicFile(File(context.filesDir, "maestro-profile-recovery.json")) + + private fun hasSnapshot(): Boolean { + val path = snapshotFile.baseFile.path + return snapshotFile.baseFile.exists() || File("$path.bak").exists() || File("$path.new").exists() + } + + override suspend fun prepare(request: MaestroFeatureRequest): Map { + validateRequest(request) + if (hasSnapshot()) throw MaestroSetupFailure("profile_recovery_pending") + val profile = read(api.getProfile()) + MaestroProfilePolicy.validateText(profile.nickname, profile.gender, profile.bio) + val preferences = read(api.getPreferredExercises()) + MaestroProfilePolicy.validatePreferences(preferences) + var addedType: Long? = null + var addedName = "" + if (request.flow == "profile-preferred-exercise") { + if (preferences.size !in 1..4) throw MaestroSetupFailure("profile_preference_baseline_unavailable") + val catalog = read(api.getPreferredExerciseTypes()) + if ( + catalog.any { it.exerciseId <= 0 || it.exerciseName.isBlank() } || + catalog.map { it.exerciseId }.toSet().size != catalog.size || + preferences.any { original -> catalog.none { it.exerciseId == original.exerciseTypeId } } + ) { + throw MaestroSetupFailure("profile_catalog_invalid") + } + val candidate = + catalog.firstOrNull { type -> preferences.none { it.exerciseTypeId == type.exerciseId } } + ?: throw MaestroSetupFailure("profile_unselected_exercise_missing") + if (candidate.exerciseName.length > 4096 || candidate.exerciseName.any { it.code < 32 }) { + throw MaestroSetupFailure("profile_catalog_invalid") + } + addedType = candidate.exerciseId + addedName = candidate.exerciseName + } + val notificationSnapshot = notificationReadiness.prepare(request.flow) + val snapshot = + JSONObject() + .put("memberId", request.memberId) + .put("runId", request.runId) + .put("flow", request.flow) + .put("nickname", profile.nickname) + .put("gender", profile.gender) + .put("bio", profile.bio) + .put("fcmToken", notificationSnapshot?.token ?: JSONObject.NULL) + .put( + "preferences", + JSONArray().apply { preferences.forEach { put(encodePreference(it)) } }, + ) + .put( + "notifications", + notificationSnapshot?.let { + JSONArray().apply { + it.settings.forEach { put(JSONObject().put("type", it.type).put("enabled", it.enabled)) } + } + } ?: JSONObject.NULL, + ) + .put("addedType", addedType ?: JSONObject.NULL) + .put("addedName", addedName) + // 서버 설정/프로필 쓰기 전에 원본 ID/값과 해당 flow의 기기 토큰을 앱 전용 파일에 보관합니다. + save(snapshot) + if (notificationSnapshot != null) { + val token = notificationSnapshot.token + val notifications = notificationSnapshot.settings + if (fcmRepository.getSavedToken() != token) throw MaestroSetupFailure("profile_fcm_identity_changed") + write( + api.patchNotiState( + token, + notifications.filter { it.type in knownTypes }.map { it.copy(enabled = false) }, + ), + ) + val prepared = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(prepared) + val expected = notifications.map { if (it.type in knownTypes) it.copy(enabled = false) else it } + if (notificationValues(prepared) != notificationValues(expected)) { + throw MaestroSetupFailure("profile_notification_prepare_failed") + } + } + return mapOf( + "E2E_PROFILE_ORIGINAL_NICKNAME" to profile.nickname, + "E2E_PROFILE_ORIGINAL_BIO" to requireNotNull(profile.bio), + "E2E_PROFILE_NEW_NICKNAME" to "e2e${request.runId}", + "E2E_PROFILE_NEW_BIO" to "e2e-intro-${request.runId}", + "E2E_PROFILE_ADDED_TYPE_ID" to (addedType?.toString() ?: ""), + "E2E_PROFILE_ADDED_NAME" to addedName, + "E2E_PROFILE_BASELINE_TYPE_IDS" to preferences.joinToString(",") { it.exerciseTypeId.toString() }, + ) + } + + override suspend fun cleanup(request: MaestroFeatureRequest) { + validateRequest(request) + if (!hasSnapshot()) return + val snapshot = JSONObject(snapshotFile.openRead().bufferedReader().use { it.readText() }) + if ( + snapshot.getString("memberId") != request.memberId || snapshot.getString("runId") != request.runId || + snapshot.getString("flow") != request.flow + ) { + throw MaestroSetupFailure("profile_recovery_identity_failed") + } + if (!snapshot.has("fcmToken") || !snapshot.has("notifications")) { + throw MaestroSetupFailure("profile_recovery_notification_invalid") + } + val notificationSnapshot = + if (snapshot.isNull("fcmToken") && snapshot.isNull("notifications")) { + if (request.flow == "profile-notification") throw MaestroSetupFailure("profile_recovery_notification_invalid") + null + } else { + if (snapshot.isNull("fcmToken") || snapshot.isNull("notifications")) { + throw MaestroSetupFailure("profile_recovery_notification_invalid") + } + val token = snapshot.getString("fcmToken") + if (token.isBlank()) throw MaestroSetupFailure("profile_fcm_identity_changed") + val notifications = decodeNotifications(snapshot.getJSONArray("notifications")) + MaestroProfilePolicy.validateNotifications(notifications) + MaestroProfileNotificationSnapshot(token, notifications) + } + val original = + PutProfileRequest( + snapshot.getString("nickname"), + snapshot.getString("gender"), + snapshot.getString("bio"), + ) + val preferences = decodePreferences(snapshot.getJSONArray("preferences")) + MaestroProfilePolicy.validateText(original.nickname, original.gender, original.bio) + val addedType = if (snapshot.isNull("addedType")) null else snapshot.getLong("addedType") + recovery.restore(original, preferences, notificationSnapshot, addedType, request.flow, request.runId) + // 이후 공용 정리가 실패해도 같은 소유권으로 재시도하도록 호스트의 최종 pm clear까지 snapshot을 보존합니다. + } + + private fun validateRequest(request: MaestroFeatureRequest) { + if (request.feature != "profile" || request.flow !in flows || !runIdPattern.matches(request.runId)) { + throw MaestroSetupFailure("profile_request_invalid") + } + } + + private fun save(snapshot: JSONObject) { + val file = snapshotFile + val content = snapshot.toString() + val stream = file.startWrite() + try { + stream.write(content.toByteArray(Charsets.UTF_8)) + file.finishWrite(stream) + } catch (_: Exception) { + file.failWrite(stream) + throw MaestroSetupFailure("profile_snapshot_write_failed") + } + if (file.openRead().bufferedReader().use { it.readText() } != content) { + throw MaestroSetupFailure("profile_snapshot_write_failed") + } + } + + private fun encodePreference(value: GetPreferredExerciseDTO): JSONObject = + JSONObject() + .put("id", value.preferredExerciseId) + .put("type", value.exerciseTypeId) + .put("name", value.exerciseName) + .put("skill", value.skillLevel) + .put("days", JSONArray(value.daysOfWeek)) + .put("image", value.imageUrl ?: JSONObject.NULL) + + private fun decodePreferences(values: JSONArray): List = + (0 until values.length()).map { index -> + val value = values.getJSONObject(index) + val days = value.getJSONArray("days") + GetPreferredExerciseDTO( + value.getLong("id"), + value.getLong("type"), + value.getString("name"), + value.getString("skill"), + (0 until days.length()).map { days.getBoolean(it) }, + if (value.isNull("image")) null else value.getString("image"), + ) + } + + private fun decodeNotifications(values: JSONArray): List = + (0 until values.length()).map { index -> + val value = values.getJSONObject(index) + NotificationStateDTO(value.getString("type"), value.getBoolean("enabled")) + } + + private fun notificationValues(values: List) = values.associate { it.type to it.enabled } + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + } + + private fun write(response: BaseResponse) { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_write_failed") + } + + private companion object { + val runIdPattern = Regex("[a-f0-9]{16}") + val knownTypes = setOf("WORKOUT_REMINDER", "CHAT_MESSAGE") + val flows = setOf("profile-edit", "profile-edit-validation", "profile-preferred-exercise", "profile-notification") + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt new file mode 100644 index 00000000..0a0a4ecd --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileFixtureModule.kt @@ -0,0 +1,17 @@ +package com.project200.undabang.e2e + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class MaestroProfileFixtureModule { + @Binds + @IntoMap + @StringKey("profile") + abstract fun bindProfileFixture(impl: MaestroProfileFixture): MaestroFeatureFixture +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt new file mode 100644 index 00000000..3933c11f --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadiness.kt @@ -0,0 +1,81 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FcmRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +// data class의 자동 toString에 실제 기기 토큰이 포함되지 않도록 일반 클래스로 보관합니다. +internal class MaestroProfileNotificationSnapshot( + val token: String, + val settings: List, +) + +internal class MaestroProfileNotificationReadiness + @Inject + constructor( + private val api: ApiService, + private val fcmRepository: FcmRepository, + ) { + suspend fun prepare(flow: String): MaestroProfileNotificationSnapshot? { + if (flow != "profile-notification") return null + // Firebase 자동 초기화 → FcmService.onNewToken → 암호화 저장소의 실제 경로만 기다립니다. + val token = + withTimeoutOrNull(TOKEN_TIMEOUT_MS) { + var saved = savedToken() + while (saved.isNullOrBlank()) { + delay(TOKEN_POLL_MS) + saved = savedToken() + } + saved + } ?: throw MaestroSetupFailure("profile_fcm_token_timeout") + // SetupActivity에는 MainViewModel의 예약이 없으므로 인증된 저장 토큰을 직접 등록합니다. + val registration = + try { + withTimeoutOrNull(REGISTRATION_TIMEOUT_MS) { fcmRepository.registerToken() } + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fcm_registration_failed") + } ?: throw MaestroSetupFailure("profile_fcm_registration_timeout") + if (registration !is BaseResult.Success) throw MaestroSetupFailure("profile_fcm_registration_failed") + requireIdentity(token) + val response = + try { + withTimeoutOrNull(SETTINGS_TIMEOUT_MS) { api.getNotiState(token) } + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fixture_read_failed") + } ?: throw MaestroSetupFailure("profile_fcm_settings_timeout") + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + val settings = response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + MaestroProfilePolicy.validateNotifications(settings) + requireIdentity(token) + return MaestroProfileNotificationSnapshot(token, settings) + } + + private suspend fun savedToken(): String? = + try { + fcmRepository.getSavedToken() + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + throw MaestroSetupFailure("profile_fcm_storage_failed") + } + + private suspend fun requireIdentity(token: String) { + if (savedToken() != token) throw MaestroSetupFailure("profile_fcm_identity_changed") + } + + private companion object { + const val TOKEN_TIMEOUT_MS = 60_000L + const val TOKEN_POLL_MS = 250L + const val REGISTRATION_TIMEOUT_MS = 30_000L + const val SETTINGS_TIMEOUT_MS = 30_000L + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt new file mode 100644 index 00000000..978ce784 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfilePolicy.kt @@ -0,0 +1,92 @@ +package com.project200.undabang.e2e + +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.usecase.ValidateNicknameUseCase + +internal object MaestroProfilePolicy { + // 플로우의 eraseText는 Maestro 2.11.0 기본값인 50자만 지우므로 더 긴 소개는 UI로 정확히 되돌릴 수 없습니다. + private const val MAX_ERASABLE_BIO_LENGTH = 50 + + // UI 복구도 실제 중복 확인을 거치므로 닉네임은 앱 규칙과 같은 기준으로 검사합니다. + private val validateNickname = ValidateNicknameUseCase() + private val genders = setOf("MALE", "FEMALE", "UNKNOWN") + private val skills = setOf("BEGINNER", "ROOKIE", "INTERMEDIATE", "ADVANCED", "SKILLED", "PRO") + + data class PreferenceRecovery( + val deleteIds: List, + val restore: List, + ) + + fun validateText( + nickname: String, + gender: String, + bio: String?, + ) { + // Maestro 2.11.0은 Android 비ASCII inputText를 자체 IME로 입력하므로 한글·문장부호도 복구할 수 있습니다. + // 빈 소개는 로드된 UI 상태로 확인하고 지운 채 저장합니다. null은 PUT 본문으로 표현할 수 없어 거부합니다. + val reason = + when { + !validateNickname(nickname) -> "profile_baseline_nickname_unsupported" + gender !in genders -> "profile_baseline_gender_unsupported" + bio == null -> "profile_baseline_bio_null" + bio.length > MAX_ERASABLE_BIO_LENGTH -> "profile_baseline_bio_too_long" + bio.any { it.isISOControl() } -> "profile_baseline_bio_control_characters" + else -> return + } + throw MaestroSetupFailure(reason) + } + + fun validatePreferences(preferences: List) { + if (preferences.map { it.preferredExerciseId }.toSet().size != preferences.size || + preferences.map { it.exerciseTypeId }.toSet().size != preferences.size || + preferences.any { + it.preferredExerciseId <= 0 || it.exerciseTypeId <= 0 || it.skillLevel !in skills || + it.daysOfWeek.size != 7 || !it.daysOfWeek.contains(true) + } + ) { + throw MaestroSetupFailure("profile_preferences_invalid") + } + } + + fun validateNotifications(settings: List) { + val types = settings.map { it.type } + if (types.any { it.isBlank() } || types.toSet().size != types.size || + "WORKOUT_REMINDER" !in types || "CHAT_MESSAGE" !in types + ) { + throw MaestroSetupFailure("profile_notifications_invalid") + } + } + + fun preferenceRecovery( + baseline: List, + current: List, + addedType: Long?, + ): PreferenceRecovery { + validatePreferences(baseline) + validatePreferences(current) + val originalIds = baseline.map { it.preferredExerciseId }.toSet() + val currentById = current.associateBy { it.preferredExerciseId } + // 기존 ID를 삭제·재생성하면 정확한 복구가 아니므로, 누락과 type 변경은 중단합니다. + if (baseline.any { currentById[it.preferredExerciseId]?.exerciseTypeId != it.exerciseTypeId }) { + throw MaestroSetupFailure("profile_preference_identity_changed") + } + val added = current.filter { it.preferredExerciseId !in originalIds } + if (added.any { it.exerciseTypeId != addedType } || added.size > 1 || + baseline.any { it.exerciseTypeId == addedType } + ) { + throw MaestroSetupFailure("profile_preference_ownership_failed") + } + return PreferenceRecovery( + deleteIds = added.map { it.preferredExerciseId }, + restore = + baseline.filter { + val actual = currentById.getValue(it.preferredExerciseId) + it.skillLevel != actual.skillLevel || it.daysOfWeek != actual.daysOfWeek + }, + ) + } + + fun preferenceValues(preferences: List) = + preferences.associate { it.preferredExerciseId to Triple(it.exerciseTypeId, it.skillLevel, it.daysOfWeek) } +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt new file mode 100644 index 00000000..f4de98e4 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroProfileRecovery.kt @@ -0,0 +1,126 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.DeletePreferredExerciseDTO +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PostPreferredExerciseDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import javax.inject.Inject + +internal class MaestroProfileRecovery + @Inject + constructor( + private val api: ApiService, + private val fcmRepository: FcmRepository, + ) { + suspend fun restore( + original: PutProfileRequest, + preferences: List, + notificationSnapshot: MaestroProfileNotificationSnapshot?, + addedType: Long?, + flow: String, + runId: String, + ) { + // 한 영역의 조회/복구 실패가 다른 영역의 원본 복구를 막지 않게 합니다. + var failed = flow == "profile-notification" && notificationSnapshot == null + + suspend fun restoreArea(block: suspend () -> Unit) { + try { + block() + } catch (_: Exception) { + failed = true + } + } + + restoreArea { + val current = read(api.getProfile()) + val allowedNicknames = + when (flow) { + "profile-edit" -> setOf(original.nickname, "e2e$runId") + "profile-edit-validation" -> setOf(original.nickname, "e2ea$runId", "e2eb$runId", "e2e615!invalid") + else -> setOf(original.nickname) + } + if (current.nickname !in allowedNicknames || current.bio !in setOf(original.bio, "e2e-intro-$runId")) { + throw MaestroSetupFailure("profile_recovery_ownership_failed") + } + val changed = + current.nickname != original.nickname || current.gender != original.gender || current.bio != original.bio + if (current.gender != original.gender || (flow != "profile-edit" && changed)) failed = true + if (changed) write(api.editProfile(original)) + val restored = read(api.getProfile()) + if (restored.nickname != original.nickname || restored.gender != original.gender || restored.bio != original.bio) { + throw MaestroSetupFailure("profile_restore_failed") + } + } + restoreArea { + val current = read(api.getPreferredExercises()) + val recovery = MaestroProfilePolicy.preferenceRecovery(preferences, current, addedType) + // 기존 행은 이 시나리오에서 변경하지 않습니다. 복구가 되어도 잘못된 쓰기는 실패입니다. + if (recovery.restore.isNotEmpty() || (flow != "profile-preferred-exercise" && recovery.deleteIds.isNotEmpty())) { + failed = true + } + if (recovery.deleteIds.isNotEmpty()) { + write(api.deletePreferredExercises(DeletePreferredExerciseDTO(recovery.deleteIds))) + } + if (recovery.restore.isNotEmpty()) { + write( + api.patchPreferredExercises( + recovery.restore.map { + PostPreferredExerciseDTO(it.exerciseTypeId, it.skillLevel, it.daysOfWeek) + }, + ), + ) + } + val restored = read(api.getPreferredExercises()) + MaestroProfilePolicy.validatePreferences(restored) + if (MaestroProfilePolicy.preferenceValues(restored) != MaestroProfilePolicy.preferenceValues(preferences)) { + throw MaestroSetupFailure("profile_preference_restore_failed") + } + } + // 다른 flow의 새 snapshot에는 알림 영역이 없습니다. 기존 snapshot은 flow와 무관하게 복구합니다. + if (notificationSnapshot != null) { + restoreArea { + val token = notificationSnapshot.token + val notifications = notificationSnapshot.settings + if (token.isBlank() || fcmRepository.getSavedToken() != token) { + throw MaestroSetupFailure("profile_fcm_identity_changed") + } + val current = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(current) + if ( + current.map { it.type }.toSet() != notifications.map { it.type }.toSet() || + values(current.filter { it.type !in knownTypes }) != values(notifications.filter { it.type !in knownTypes }) + ) { + throw MaestroSetupFailure("profile_recovery_ownership_failed") + } + if (flow != "profile-notification" && values(current) != values(notifications)) failed = true + if (values(current) != values(notifications)) write(api.patchNotiState(token, notifications)) + val restored = read(api.getNotiState(token)) + MaestroProfilePolicy.validateNotifications(restored) + if (fcmRepository.getSavedToken() != token || values(restored) != values(notifications)) { + throw MaestroSetupFailure("profile_notification_restore_failed") + } + } + } + // 성공해도 snapshot은 호스트의 최종 pm clear까지 남겨 같은 실행의 재시도를 허용합니다. + if (failed) throw MaestroSetupFailure("profile_restore_failed") + } + + private fun values(values: List) = values.associate { it.type to it.enabled } + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("profile_fixture_read_failed") + } + + private fun write(response: BaseResponse) { + if (!response.succeed) throw MaestroSetupFailure("profile_fixture_write_failed") + } + + private companion object { + val knownTypes = setOf("WORKOUT_REMINDER", "CHAT_MESSAGE") + } + } diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt new file mode 100644 index 00000000..f438ec67 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileNotificationReadinessTest.kt @@ -0,0 +1,214 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.NotificationStateDTO +import com.project200.domain.model.BaseResult +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MaestroProfileNotificationReadinessTest { + private val api = mockk() + private val fcm = mockk() + private val readiness = MaestroProfileNotificationReadiness(api, fcm) + private val token = "unit-test-device-identity" + private val notifications = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("NEW_SERVER_TYPE", true), + ) + + @Test + fun `FCM callback 저장을 기다린 뒤 실제 등록 성공과 같은 기기의 원본 행을 요구한다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(null, "", " ", token) + val snapshot = requireNotNull(readiness.prepare("profile-notification")) + assertThat(testScheduler.currentTime).isEqualTo(750L) + assertThat(snapshot.token).isEqualTo(token) + assertThat(snapshot.settings).containsExactlyElementsIn(notifications) + coVerifySequence { + fcm.getSavedToken() + fcm.getSavedToken() + fcm.getSavedToken() + fcm.getSavedToken() + fcm.registerToken() + fcm.getSavedToken() + api.getNotiState(token) + fcm.getSavedToken() + } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `다른 profile flow는 FCM 토큰과 알림 API를 요구하지 않는다`() = + runTest { + for (flow in listOf("profile-edit", "profile-edit-validation", "profile-preferred-exercise")) { + assertThat(readiness.prepare(flow)).isNull() + } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `저장 토큰이 없으면 제한 시간에 실패하고 등록이나 기본 행을 만들지 않는다`() = + runTest { + coEvery { fcm.getSavedToken() } returns null + expectFailure("profile_fcm_token_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(60_000L) + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `마지막 polling 구간에 저장된 토큰도 제한 시간 전에 등록할 수 있다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } coAnswers { + if (testScheduler.currentTime >= 59_750L) token else null + } + assertThat(readiness.prepare("profile-notification")?.token).isEqualTo(token) + assertThat(testScheduler.currentTime).isEqualTo(59_750L) + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `토큰 저장소 오류를 누락 토큰으로 재시도하지 않는다`() = + runTest { + coEvery { fcm.getSavedToken() } throws IllegalStateException(token) + expectFailure("profile_fcm_storage_failed") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(0L) + coVerify(exactly = 1) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `백엔드 등록 Error는 재시도나 설정 조회 없이 고정 오류로 실패한다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } returns BaseResult.Error("PRIVATE", token, IllegalStateException(token)) + expectFailure("profile_fcm_registration_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 예외에 포함된 원문은 결과 오류에 전달하지 않는다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } throws IllegalStateException(token) + expectFailure("profile_fcm_registration_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 응답이 끝나지 않으면 제한 시간에 실패한다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } coAnswers { + delay(30_001L) + BaseResult.Success(Unit) + } + expectFailure("profile_fcm_registration_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(30_000L) + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `등록 중 토큰이 바뀌면 어떤 기기의 설정도 읽지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(token, "rotated-device") + expectFailure("profile_fcm_identity_changed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { fcm.registerToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + } + + @Test + fun `설정 조회 중 토큰이 바뀌면 원본 snapshot을 반환하지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returnsMany listOf(token, token, "rotated-device") + expectFailure("profile_fcm_identity_changed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `실패한 설정 응답에 행이 있어도 원본으로 인정하지 않는다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } returns BaseResponse(false, "PRIVATE", token, notifications) + expectFailure("profile_fixture_read_failed") { readiness.prepare("profile-notification") } + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `등록 성공이어도 필수 알림 행이 없으면 기본값으로 대체하지 않는다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } returns BaseResponse(true, "OK", "", notifications.drop(1)) + expectFailure("profile_notifications_invalid") { readiness.prepare("profile-notification") } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `설정 조회가 끝나지 않으면 제한 시간에 실패한다`() = + runTest { + baseline() + coEvery { api.getNotiState(token) } coAnswers { + delay(30_001L) + BaseResponse(true, "OK", "", notifications) + } + expectFailure("profile_fcm_settings_timeout") { readiness.prepare("profile-notification") } + assertThat(testScheduler.currentTime).isEqualTo(30_000L) + coVerify(exactly = 1) { api.getNotiState(token) } + } + + @Test + fun `상위 작업 취소는 readiness 실패로 삼키지 않는다`() = + runTest { + baseline() + coEvery { fcm.registerToken() } throws CancellationException("cancelled") + try { + readiness.prepare("profile-notification") + throw AssertionError("취소된 등록이 성공으로 처리되었습니다") + } catch (_: CancellationException) { + coVerify(exactly = 0) { api.getNotiState(any()) } + } + } + + private fun baseline() { + coEvery { fcm.getSavedToken() } returns token + coEvery { fcm.registerToken() } returns BaseResult.Success(Unit) + coEvery { api.getNotiState(token) } returns BaseResponse(true, "OK", "", notifications) + } + + private suspend fun expectFailure( + reason: String, + operation: suspend () -> Unit, + ) { + try { + operation() + throw AssertionError("준비 실패가 성공으로 처리되었습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo(reason) + assertThat(failure.cause).isNull() + } + } +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt new file mode 100644 index 00000000..e32f78f5 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfilePolicyTest.kt @@ -0,0 +1,106 @@ +package com.project200.undabang.e2e + +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.NotificationStateDTO +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class MaestroProfilePolicyTest { + private val baseline = preference(10, 1) + + @Test + fun `원본 preference ID가 사라지면 재생성하지 않고 실패한다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(preference(11, 1)), 2) + } + } + + @Test + fun `이번 실행의 추가 종목만 삭제하고 원본 값만 수정한다`() { + val changed = baseline.copy(skillLevel = "PRO") + val recovery = MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(changed, preference(20, 2)), 2) + assertEquals(listOf(20L), recovery.deleteIds) + assertEquals(listOf(baseline), recovery.restore) + } + + @Test + fun `소유하지 않은 추가 종목은 삭제하지 않는다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(baseline, preference(30, 3)), 2) + } + } + + @Test + fun `알림의 서버 확장 type은 원문과 값을 보존한다`() { + val settings = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("SERVER_NEW_TYPE", true), + ) + MaestroProfilePolicy.validateNotifications(settings) + assertEquals("SERVER_NEW_TYPE", settings.last().type) + assertEquals(true, settings.last().enabled) + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateNotifications(settings.filterNot { it.type == "CHAT_MESSAGE" }) + } + } + + @Test + fun `앱이 허용하는 한글 닉네임과 문장부호 소개는 복구 가능한 기준으로 받는다`() { + MaestroProfilePolicy.validateText("운동하는사람1", "UNKNOWN", "주 3회 헬스, 러닝 좋아해요!") + MaestroProfilePolicy.validateText("OriginalUser", "FEMALE", "a".repeat(50)) + } + + @Test + fun `UI로 정확히 복구할 수 없는 기준 텍스트는 mutation 전에 거부한다`() { + for (nickname in listOf("", "닉네임!", "a".repeat(31))) { + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText(nickname, "MALE", "intro") + } + assertEquals("profile_baseline_nickname_unsupported", failure.reason) + } + val unsupportedBios = + listOf( + null to "profile_baseline_bio_null", + "line\nbreak" to "profile_baseline_bio_control_characters", + "tab\tbio" to "profile_baseline_bio_control_characters", + "a".repeat(51) to "profile_baseline_bio_too_long", + ) + for ((bio, reason) in unsupportedBios) { + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("OriginalUser", "MALE", bio) + } + assertEquals(reason, failure.reason) + } + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("OriginalUser", "OTHER", "intro") + } + assertEquals("profile_baseline_gender_unsupported", failure.reason) + } + + @Test + fun `여러 기준 조건이 실패하면 닉네임 실패를 먼저 알린다`() { + val failure = + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.validateText("닉네임!", "OTHER", "line\nbreak") + } + assertEquals("profile_baseline_nickname_unsupported", failure.reason) + } + + @Test + fun `동일 type에 여러 ID가 있으면 소유권을 추측하지 않는다`() { + assertThrows(MaestroSetupFailure::class.java) { + MaestroProfilePolicy.preferenceRecovery(listOf(baseline), listOf(baseline, preference(20, 2), preference(21, 2)), 2) + } + } + + private fun preference( + id: Long, + type: Long, + ) = GetPreferredExerciseDTO(id, type, "Sport$type", "BEGINNER", listOf(true, false, false, false, false, false, false), null) +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt new file mode 100644 index 00000000..128245b0 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroProfileRecoveryTest.kt @@ -0,0 +1,292 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.DeletePreferredExerciseDTO +import com.project200.data.dto.GetPreferredExerciseDTO +import com.project200.data.dto.GetProfileDTO +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.dto.PostPreferredExerciseDTO +import com.project200.data.dto.PutProfileRequest +import com.project200.domain.repository.FcmRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MaestroProfileRecoveryTest { + private val api = mockk() + private val fcm = mockk() + private val recovery = MaestroProfileRecovery(api, fcm) + private val original = PutProfileRequest("OriginalUser", "UNKNOWN", "Original intro") + private val runId = "0123456789abcdef" + private val token = "unit-test-device-identity" + private val preference = + GetPreferredExerciseDTO(10, 1, "Sport", "BEGINNER", listOf(true, false, false, false, false, false, false), null) + private val notifications = + listOf( + NotificationStateDTO("WORKOUT_REMINDER", true), + NotificationStateDTO("CHAT_MESSAGE", false), + NotificationStateDTO("NEW_SERVER_TYPE", true), + ) + private val notificationSnapshot = MaestroProfileNotificationSnapshot(token, notifications) + + @Test + fun `원본 ID를 유지하고 이번 실행의 추가 ID만 삭제한다`() = + runTest { + baseline() + val extra = preference.copy(preferredExerciseId = 20, exerciseTypeId = 2) + coEvery { api.getPreferredExercises() } returnsMany listOf(success(listOf(preference, extra)), success(listOf(preference))) + coEvery { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } returns success(null) + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + coVerify(exactly = 1) { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + } + + @Test + fun `원문 type 복구와 재조회를 수행하되 다른 flow의 알림 변경은 실패로 남긴다`() = + runTest { + baseline() + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + val changed = notifications.map { if (it.type == "CHAT_MESSAGE") it.copy(enabled = true) else it } + coEvery { api.getNotiState(token) } returnsMany listOf(success(changed), success(notifications)) + coEvery { api.patchNotiState(token, notifications) } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-edit", runId) } + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + coVerify(exactly = 2) { api.getNotiState(token) } + } + + @Test + fun `빈 소개 원본은 빈 문자열 PUT과 정확한 GET 재조회로 복구한다`() = + runTest { + baseline() + val emptyOriginal = original.copy(bio = "") + coEvery { api.getProfile() } returnsMany + listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile(bio = ""))) + coEvery { api.editProfile(emptyOriginal) } returns success(null) + MaestroProfilePolicy.validateText(emptyOriginal.nickname, emptyOriginal.gender, emptyOriginal.bio) + + recovery.restore(emptyOriginal, listOf(preference), null, null, "profile-edit", runId) + + coVerify(exactly = 1) { api.editProfile(emptyOriginal) } + coVerify(exactly = 2) { api.getProfile() } + coVerify(exactly = 0) { fcm.getSavedToken() } + } + + @Test + fun `빈 소개 PUT 성공 뒤 null이나 다른 문자열을 읽으면 복구 실패다`() = + runTest { + baseline() + val emptyOriginal = original.copy(bio = "") + coEvery { api.editProfile(emptyOriginal) } returns success(null) + for (readback in listOf(null, " ", "e2e-intro-$runId")) { + coEvery { api.getProfile() } returnsMany + listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile(bio = readback))) + + expectFailure { recovery.restore(emptyOriginal, listOf(preference), null, null, "profile-edit", runId) } + } + coVerify(exactly = 3) { api.editProfile(emptyOriginal) } + coVerify(exactly = 6) { api.getProfile() } + } + + @Test + fun `원본 preference 변경은 같은 type PATCH로 복구해도 테스트 실패를 유지한다`() = + runTest { + baseline() + coEvery { api.getPreferredExercises() } returnsMany + listOf(success(listOf(preference.copy(skillLevel = "PRO"))), success(listOf(preference))) + coEvery { + api.patchPreferredExercises(listOf(PostPreferredExerciseDTO(1, "BEGINNER", preference.daysOfWeek))) + } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) } + coVerify(exactly = 1) { + api.patchPreferredExercises(listOf(PostPreferredExerciseDTO(1, "BEGINNER", preference.daysOfWeek))) + } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + } + + @Test + fun `알림 조회 오류는 빈 성공이 아니며 다른 영역의 복구를 막지 않는다`() = + runTest { + baseline() + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + coEvery { api.getNotiState(token) } returns BaseResponse(false, "ERROR", "", emptyList()) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-edit", runId) } + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `FCM identity가 바뀌면 새 기기로 원본 설정을 보내지 않는다`() = + runTest { + baseline() + coEvery { fcm.getSavedToken() } returns "different-device" + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + coVerify(exactly = 0) { api.getNotiState(any()) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `서버 성공 응답 뒤 재조회 값이 다르면 복구 성공을 반환하지 않는다`() = + runTest { + baseline() + val changed = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } returns success(changed) + coEvery { api.patchNotiState(token, notifications) } returns success(null) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + } + + @Test + fun `실패한 PATCH는 응답 data가 있어도 복구 성공이 아니다`() = + runTest { + baseline() + val changed = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } returns success(changed) + coEvery { api.patchNotiState(token, notifications) } returns BaseResponse(false, "ERROR", "", Unit) + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + } + + @Test + fun `알림 snapshot이 없는 다른 flow는 알림 저장소와 API 없이 원본을 복구한다`() = + runTest { + coEvery { api.getProfile() } returnsMany listOf(success(profile("e2e$runId", "e2e-intro-$runId")), success(profile())) + coEvery { api.editProfile(original) } returns success(null) + coEvery { api.getPreferredExercises() } returns success(listOf(preference)) + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { api.getNotiState(any()) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `알림 flow의 누락 snapshot은 원본이 같아도 실패하고 다른 영역을 조회한다`() = + runTest { + baseline() + expectFailure { recovery.restore(original, listOf(preference), null, null, "profile-notification", runId) } + coVerify(exactly = 2) { api.getProfile() } + coVerify(exactly = 2) { api.getPreferredExercises() } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + } + + @Test + fun `같은 실행을 다시 복구해도 이미 복구한 프로필을 다시 쓰지 않는다`() = + runTest { + baseline() + var current = profile("e2e$runId", "e2e-intro-$runId") + coEvery { api.getProfile() } coAnswers { success(current) } + coEvery { api.editProfile(original) } coAnswers { + current = profile() + success(null) + } + + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + recovery.restore(original, listOf(preference), null, null, "profile-edit", runId) + + assertThat(current).isEqualTo(profile()) + coVerify(exactly = 1) { api.editProfile(original) } + coVerify(exactly = 4) { api.getProfile() } + coVerify(exactly = 0) { api.deletePreferredExercises(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + coVerify(exactly = 0) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `같은 실행의 추가 ID를 이미 삭제했으면 원본 ID와 기기 설정을 보존한다`() = + runTest { + baseline() + val extra = preference.copy(preferredExerciseId = 20, exerciseTypeId = 2) + var current = listOf(preference, extra) + coEvery { api.getPreferredExercises() } coAnswers { success(current) } + coEvery { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } coAnswers { + current = listOf(preference) + success(null) + } + + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + recovery.restore(original, listOf(preference), notificationSnapshot, 2, "profile-preferred-exercise", runId) + + assertThat(current).containsExactly(preference) + coVerify(exactly = 1) { api.deletePreferredExercises(DeletePreferredExerciseDTO(listOf(20))) } + coVerify(exactly = 4) { api.getPreferredExercises() } + coVerify(exactly = 0) { api.editProfile(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + coVerify(exactly = 4) { fcm.getSavedToken() } + coVerify(exactly = 4) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + coVerify(exactly = 0) { fcm.registerToken() } + } + + @Test + fun `같은 실행의 알림을 다시 복구하면 같은 기기의 원본 행을 재조회만 한다`() = + runTest { + baseline() + var current = notifications.map { if (it.type == "WORKOUT_REMINDER") it.copy(enabled = false) else it } + coEvery { api.getNotiState(token) } coAnswers { success(current) } + coEvery { api.patchNotiState(token, notifications) } coAnswers { + current = notifications + success(null) + } + + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + + assertThat(current).containsExactlyElementsIn(notifications) + coVerify(exactly = 1) { api.patchNotiState(token, notifications) } + coVerify(exactly = 4) { api.getNotiState(token) } + coVerify(exactly = 4) { fcm.getSavedToken() } + coVerify(exactly = 0) { fcm.registerToken() } + coVerify(exactly = 0) { api.editProfile(any()) } + coVerify(exactly = 0) { api.deletePreferredExercises(any()) } + coVerify(exactly = 0) { api.postPreferredExercises(any()) } + coVerify(exactly = 0) { api.patchPreferredExercises(any()) } + } + + @Test + fun `복구 완료 뒤 재시도해도 바뀐 FCM identity는 거부한다`() = + runTest { + baseline() + recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) + coEvery { fcm.getSavedToken() } returns "different-device" + + expectFailure { recovery.restore(original, listOf(preference), notificationSnapshot, null, "profile-notification", runId) } + + coVerify(exactly = 2) { api.getNotiState(token) } + coVerify(exactly = 0) { api.patchNotiState(any(), any()) } + coVerify(exactly = 0) { fcm.registerToken() } + } + + private fun baseline() { + coEvery { api.getProfile() } returns success(profile()) + coEvery { api.getPreferredExercises() } returns success(listOf(preference)) + coEvery { api.getNotiState(token) } returns success(notifications) + coEvery { fcm.getSavedToken() } returns token + } + + private fun profile( + nickname: String = original.nickname, + bio: String? = original.bio, + ) = GetProfileDTO(null, null, nickname, original.gender, "1990-01-01", bio, 0, 0, 0, emptyList()) + + private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) + + private suspend fun expectFailure(operation: suspend () -> Unit) { + try { + operation() + throw AssertionError("복구 실패가 성공으로 처리되었습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo("profile_restore_failed") + } + } +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt index 3bdff5e1..0f8ad469 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt @@ -71,6 +71,7 @@ class ProfileEditFragment : Fragment() { val newProfileImageUri by viewModel.newProfileImageUri.collectAsStateWithLifecycle() val nicknameValidationState by viewModel.nicknameValidationState.collectAsStateWithLifecycle() val isNicknameChecked by viewModel.isNicknameChecked.collectAsStateWithLifecycle() + val isLoaded by viewModel.isLoaded.collectAsStateWithLifecycle() ProfileEditScreen( nickname = nickname, @@ -80,6 +81,7 @@ class ProfileEditFragment : Fragment() { newProfileImageUri = newProfileImageUri, nicknameValidationState = nicknameValidationState, isNicknameChecked = isNicknameChecked, + isLoaded = isLoaded, onNicknameChange = viewModel::updateNickname, onIntroductionChange = viewModel::updateIntroduction, onGenderSelect = viewModel::selectGender, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt index 8772c6f3..64c9e66b 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditScreen.kt @@ -31,12 +31,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage @@ -60,6 +64,7 @@ import com.project200.undabang.profile.mypage.ProfileEditFragment.Companion.MALE import com.project200.undabang.profile.utils.NicknameValidationState import com.project200.undabang.presentation.R as PresentationR +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ProfileEditScreen( nickname: String, @@ -77,12 +82,16 @@ fun ProfileEditScreen( onCompleteClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, + isLoaded: Boolean = true, ) { Column( modifier = modifier .fillMaxSize() - .background(ColorWhite300), + .background(ColorWhite300) + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + .semantics { testTagsAsResourceId = true } + .testTag(if (isLoaded) "profile_edit_loaded" else "profile_edit_loading"), ) { UndabangTopBar( title = stringResource(R.string.profile_edit), @@ -91,6 +100,7 @@ fun ProfileEditScreen( PrimaryButton( text = stringResource(PresentationR.string.complete), onClick = onCompleteClick, + enabled = isLoaded, isCompact = true, modifier = Modifier.padding(end = 20.dp), ) @@ -203,7 +213,9 @@ private fun NicknameSection( UndabangTextField( value = nickname, onValueChange = onNicknameChange, + // Maestro가 중복 확인 전후 상태를 읽고 닉네임을 다시 입력할 지점 modifier = Modifier.weight(1f), + inputModifier = Modifier.testTag("profile_edit_nickname_field"), variant = TextFieldVariant.Underline, maxLength = 30, ) @@ -348,7 +360,13 @@ private fun IntroductionSection( onChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = + modifier + .fillMaxWidth() + // 빈 입력의 hint를 원문으로 오인하지 않도록 실제 값의 상태만 비음성 testTag로 노출합니다. + .testTag(if (introduction.isEmpty()) "profile_edit_introduction_empty" else "profile_edit_introduction_present"), + ) { Text( text = stringResource(R.string.profile_edit_introduction), style = MaterialTheme.typography.contentBold, @@ -365,6 +383,7 @@ private fun IntroductionSection( Modifier .fillMaxWidth() .defaultMinSize(minHeight = 120.dp), + inputModifier = Modifier.testTag("profile_edit_introduction_field"), ) } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt index 460d5b9f..d3cbee51 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditViewModel.kt @@ -34,6 +34,9 @@ class ProfileEditViewModel private val _initProfile = MutableStateFlow(null) val initProfile: StateFlow = _initProfile.asStateFlow() + private val _isLoaded = MutableStateFlow(false) + val isLoaded: StateFlow = _isLoaded.asStateFlow() + private val _nickname = MutableStateFlow("") val nickname: StateFlow = _nickname.asStateFlow() @@ -66,6 +69,7 @@ class ProfileEditViewModel } fun getProfile() { + _isLoaded.value = false viewModelScope.launch { when (val result = getUserProfileUseCase()) { is BaseResult.Success -> { @@ -74,6 +78,7 @@ class ProfileEditViewModel _nickname.value = profile.nickname _gender.value = profile.gender _introduction.value = profile.bio.orEmpty() + _isLoaded.value = true } is BaseResult.Error -> { @@ -104,6 +109,7 @@ class ProfileEditViewModel } fun completeEditProfile() { + if (!_isLoaded.value) return viewModelScope.launch { val currentNickname = _nickname.value val currentGender = _gender.value ?: return@launch diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt index 22a69cf1..10ef5d5e 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseFragment.kt @@ -49,6 +49,7 @@ class PreferredExerciseFragment : Fragment() { val uiModels by viewModel.exerciseUiModels.collectAsStateWithLifecycle() val selectedUiModels by viewModel.selectedExerciseUiModels.collectAsStateWithLifecycle() val completionState by viewModel.completionState.collectAsStateWithLifecycle() + val isLoaded by viewModel.isLoaded.collectAsStateWithLifecycle() BackHandler(enabled = step == PreferredExerciseStep.DETAIL) { step = PreferredExerciseStep.TYPE @@ -59,7 +60,7 @@ class PreferredExerciseFragment : Fragment() { nickname = viewModel.nickname, uiModels = uiModels, selectedUiModels = selectedUiModels, - isLoading = completionState is CompletionState.Loading, + isLoading = !isLoaded || completionState is CompletionState.Loading, formatter = formatter, onBackClick = { if (step == PreferredExerciseStep.DETAIL) { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt index 73a81701..c5be47bb 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseScreen.kt @@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -28,12 +30,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,6 +72,7 @@ enum class PreferredExerciseStep { DETAIL, } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun PreferredExerciseScreen( step: PreferredExerciseStep, @@ -81,7 +89,14 @@ fun PreferredExerciseScreen( onSkillClick: (exerciseTypeId: Long, skill: SkillLevel) -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = + modifier + .fillMaxSize() + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + .semantics { testTagsAsResourceId = true } + .testTag(if (isLoading) "preferred_exercise_loading" else "preferred_exercise_${step.name.lowercase()}_loaded"), + ) { Column( modifier = Modifier @@ -109,21 +124,23 @@ fun PreferredExerciseScreen( }, ) - when (step) { - PreferredExerciseStep.TYPE -> - PreferredExerciseTypeContent( - nickname = nickname, - uiModels = uiModels, - onTypeClick = onTypeClick, - onLimitReached = onTypeLimitReached, - ) - PreferredExerciseStep.DETAIL -> - PreferredExerciseDetailContent( - items = selectedUiModels, - formatter = formatter, - onDayClick = onDayClick, - onSkillClick = onSkillClick, - ) + if (!isLoading) { + when (step) { + PreferredExerciseStep.TYPE -> + PreferredExerciseTypeContent( + nickname = nickname, + uiModels = uiModels, + onTypeClick = onTypeClick, + onLimitReached = onTypeLimitReached, + ) + PreferredExerciseStep.DETAIL -> + PreferredExerciseDetailContent( + items = selectedUiModels, + formatter = formatter, + onDayClick = onDayClick, + onSkillClick = onSkillClick, + ) + } } } @@ -148,11 +165,13 @@ private fun PreferredExerciseTypeContent( modifier = modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp), ) { Spacer(Modifier.height(44.dp)) Text( text = stringResource(R.string.preferred_exercise_type_title, nickname), + modifier = Modifier.testTag("preferred_exercise_type_start"), style = MaterialTheme.typography.contentBold, color = ColorBlack, ) @@ -201,6 +220,9 @@ private fun PreferredExerciseTypeChip( indication = null, onClick = onClick, ) + // Compose의 selected 상태는 Maestro에 checked로 노출됩니다. 계정별 종목은 이름 대신 ID로 식별합니다 + .semantics { selected = uiModel.isSelected } + .testTag("preferred_exercise_type_${uiModel.exercise.exerciseTypeId}") .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -261,6 +283,7 @@ private fun PreferredExerciseDetailCard( modifier = modifier .fillMaxWidth() + .testTag("preferred_exercise_card_${uiModel.exercise.exerciseTypeId}") .padding(horizontal = 20.dp) .shadow(elevation = 2.dp, shape = RoundedCornerShape(15.dp), clip = false) .clip(RoundedCornerShape(15.dp)) @@ -326,6 +349,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(resId), selected = uiModel.selectedDays[index], onClick = { onDayClick(index) }, + testTag = "preferred_exercise_${uiModel.exercise.exerciseTypeId}_day_$index", modifier = Modifier.weight(1f).padding(5.dp).height(40.dp), ) } @@ -349,6 +373,7 @@ private fun PreferredExerciseDetailCard( text = stringResource(skill.resId), selected = uiModel.skillLevel == skill, onClick = { onSkillClick(skill) }, + testTag = "preferred_exercise_${uiModel.exercise.exerciseTypeId}_skill_${skill.name}", modifier = Modifier.weight(1f).padding(5.dp).height(36.dp), ) } @@ -367,6 +392,7 @@ private fun DayOrSkillButton( text: String, selected: Boolean, onClick: () -> Unit, + testTag: String, modifier: Modifier = Modifier, ) { val backgroundColor = if (selected) ColorMain else ColorGray300 @@ -377,6 +403,9 @@ private fun DayOrSkillButton( modifier .clip(RoundedCornerShape(8.dp)) .background(backgroundColor) + // 요일/숙련도 버튼을 같은 모양으로 공유해 호출부에서 구분용 testTag를 넘깁니다 + .semantics { this.selected = selected } + .testTag(testTag) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt index 3e4584e2..2b5d536b 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModel.kt @@ -51,6 +51,9 @@ class PreferredExerciseViewModel private val _exerciseUiModels = MutableStateFlow>(emptyList()) val exerciseUiModels: StateFlow> = _exerciseUiModels.asStateFlow() + private val _isLoaded = MutableStateFlow(false) + val isLoaded: StateFlow = _isLoaded.asStateFlow() + val selectedExerciseUiModels: StateFlow> = _exerciseUiModels .map { list -> list.filter { it.isSelected } } @@ -80,6 +83,7 @@ class PreferredExerciseViewModel exerciseTypes.value = allTypesResult.data preferredExercise.value = exerciseResult.data updateUiModels() + _isLoaded.value = true } } } @@ -181,6 +185,7 @@ class PreferredExerciseViewModel * 선호 운동 설정 완료 */ fun completePreferredExerciseChanges() { + if (!_isLoaded.value || _completionState.value is CompletionState.Loading) return _completionState.value = CompletionState.Loading val selectedUiModels = _exerciseUiModels.value.filter { it.isSelected } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index 843fe301..b4f0b68d 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -48,12 +48,16 @@ class NotificationFragment : Fragment() { ComposeView(requireContext()).apply { applyAppTheme { val states by viewModel.notificationStates.collectAsStateWithLifecycle() + val syncState by viewModel.syncState.collectAsStateWithLifecycle() + val hasDevicePermission by viewModel.hasDevicePermission.collectAsStateWithLifecycle() val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false val isChattingOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false NotificationScreen( isExerciseOn = isExerciseOn, isChattingOn = isChattingOn, + syncState = syncState, + hasDevicePermission = hasDevicePermission, onNavigateBack = { findNavController().popBackStack() }, onExerciseToggle = { viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, it) }, onChattingToggle = { viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, it) }, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt index b1a24bb4..a58d7c12 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -15,8 +16,12 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.project200.presentation.compose.components.layout.UndabangScaffold @@ -29,6 +34,7 @@ import com.project200.presentation.compose.theme.ColorWhite300 import com.project200.presentation.compose.theme.contentRegular import com.project200.undabang.feature.profile.R +@OptIn(ExperimentalComposeUiApi::class) @Composable fun NotificationScreen( isExerciseOn: Boolean, @@ -37,13 +43,16 @@ fun NotificationScreen( onExerciseToggle: (Boolean) -> Unit, onChattingToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, + syncState: NotificationSyncState = NotificationSyncState.Ready, + hasDevicePermission: Boolean = true, ) { UndabangScaffold( - modifier = modifier, + // Maestro id 셀렉터가 이 화면 하위 testTag를 android:id처럼 찾게 합니다 + modifier = modifier.semantics { testTagsAsResourceId = true }, topBar = { UndabangTopBar( title = stringResource(R.string.notification), - onNavigationClick = onNavigateBack, + onNavigationClick = { if (syncState != NotificationSyncState.Saving) onNavigateBack() }, ) }, ) { innerPadding -> @@ -51,18 +60,35 @@ fun NotificationScreen( modifier = Modifier .padding(innerPadding) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .testTag("notification_${syncState.name.lowercase()}"), ) { - NotificationSwitchRow( - labelRes = R.string.exercise_notification, - checked = isExerciseOn, - onCheckedChange = onExerciseToggle, - ) - NotificationSwitchRow( - labelRes = R.string.chatting_notification, - checked = isChattingOn, - onCheckedChange = onChattingToggle, - ) + when (syncState) { + NotificationSyncState.Loading -> CircularProgressIndicator() + NotificationSyncState.Error -> Text(text = stringResource(R.string.noti_state_failed)) + NotificationSyncState.Ready, NotificationSyncState.Saving -> { + if (!hasDevicePermission) { + Text( + text = stringResource(R.string.noti_permission_announce), + modifier = Modifier.testTag("notification_permission_denied"), + ) + } + NotificationSwitchRow( + labelRes = R.string.exercise_notification, + checked = isExerciseOn, + onCheckedChange = onExerciseToggle, + testTag = "exercise_noti_switch", + enabled = syncState == NotificationSyncState.Ready, + ) + NotificationSwitchRow( + labelRes = R.string.chatting_notification, + checked = isChattingOn, + onCheckedChange = onChattingToggle, + testTag = "chatting_noti_switch", + enabled = syncState == NotificationSyncState.Ready, + ) + } + } } } } @@ -72,7 +98,9 @@ private fun NotificationSwitchRow( @StringRes labelRes: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit, + testTag: String, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Column(modifier = modifier.fillMaxWidth()) { Row( @@ -92,6 +120,9 @@ private fun NotificationSwitchRow( Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, + // 운동/채팅 알림 스위치를 구분해 토글·상태 검증에 씁니다 + modifier = Modifier.testTag(testTag), colors = SwitchDefaults.colors( checkedThumbColor = ColorWhite300, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index d439162c..26f4efdd 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -11,9 +11,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject +enum class NotificationSyncState { + Loading, + Ready, + Saving, + Error, +} + @HiltViewModel class NotificationViewModel @Inject @@ -24,66 +30,44 @@ class NotificationViewModel private val _notificationStates = MutableStateFlow>(emptyList()) val notificationStates: StateFlow> = _notificationStates + private val _syncState = MutableStateFlow(NotificationSyncState.Loading) + val syncState: StateFlow = _syncState + private val _permissionRequestTrigger = MutableStateFlow(false) val permissionRequestTrigger: StateFlow = _permissionRequestTrigger private val _toastMessage = MutableStateFlow(null) val toastMessage: StateFlow = _toastMessage - // 디바이스 권한 상태 - private var hasDevicePermission: Boolean = false - - // 대기 중인 알림 설정 타입 + private var serverStates: List = emptyList() + private val _hasDevicePermission = MutableStateFlow(false) + val hasDevicePermission: StateFlow = _hasDevicePermission private var pendingSettingType: NotificationType? = null - - private var isInitialized = false + private var busy = false fun initNotiState(isGranted: Boolean) { - this.hasDevicePermission = isGranted - if (isGranted) { - getNotificationState() - } else { - _notificationStates.value = - listOf( - NotificationState(NotificationType.WORKOUT_REMINDER, false), - NotificationState(NotificationType.CHAT_MESSAGE, false), - ) - } - isInitialized = true + updateNotiStateByPermission(isGranted) } fun updateNotiStateByPermission(isGranted: Boolean) { - if (!isInitialized) { - initNotiState(isGranted) - return - } - - val wasGranted = this.hasDevicePermission - this.hasDevicePermission = isGranted - - // 권한 거부 - if (!isGranted) { - // 모든 알림 설정을 false로 조정 - Timber.tag("NotificationViewModel").e("권한 거부로 모든 알림 설정 비활성화") - _notificationStates.value = _notificationStates.value.map { it.copy(enabled = false) } - } else if (!wasGranted) { // 권한 거부에서 허용으로 변경된 경우 - Timber.tag("NotificationViewModel").e("권한 허용으로 변경") - pendingSettingType?.let { - updateSetting(it, true) - pendingSettingType = null - } - } - } - - private fun getNotificationState() { + _hasDevicePermission.value = isGranted + publishStates() + if (busy) return + busy = true + _syncState.value = NotificationSyncState.Loading viewModelScope.launch { - when (val result = getNotiStateUseCase()) { - is BaseResult.Success -> { - _notificationStates.value = result.data - } - is BaseResult.Error -> { - _toastMessage.value = result.message + try { + if (readStates()) { + val pending = pendingSettingType + if (_hasDevicePermission.value && pending != null) { + pendingSettingType = null + persist(pending, true) + } else { + _syncState.value = NotificationSyncState.Ready + } } + } finally { + busy = false } } } @@ -92,43 +76,72 @@ class NotificationViewModel type: NotificationType, isEnabled: Boolean, ) { - // 권한이 없는 경우 - if (isEnabled && !hasDevicePermission) { - // 권한 허용 후에 적용할 설정을 저장 - pendingSettingType = type - _permissionRequestTrigger.value = true + if (busy || _syncState.value != NotificationSyncState.Ready || type == NotificationType.UNKNOWN) return + if (!_hasDevicePermission.value) { + if (isEnabled) { + pendingSettingType = type + _permissionRequestTrigger.value = true + } return } - updateSetting(type, isEnabled) + busy = true + _syncState.value = NotificationSyncState.Saving + viewModelScope.launch { + try { + persist(type, isEnabled) + } finally { + busy = false + } + } } - private fun updateSetting( + private suspend fun persist( type: NotificationType, enabled: Boolean, ) { - Timber.tag("NotificationViewModel").e("알림 설정 변경 요청: $type, enabled: $enabled") - viewModelScope.launch { - // UI를 먼저 변경 - val originalStates = _notificationStates.value - val newStates = - originalStates.map { - // 전달받은 type에 해당하는 아이템의 enabled 값만 변경 - if (it.type == type) it.copy(enabled = enabled) else it - } - _notificationStates.value = newStates + _syncState.value = NotificationSyncState.Saving + // 전체 목록의 UNKNOWN 재전송은 원래 서버 type을 잃습니다. 변경한 행만 PATCH합니다. + when (val result = updateNotiSettingUseCase(listOf(NotificationState(type, enabled)))) { + is BaseResult.Success -> { + if (readStates(NotificationState(type, enabled))) _syncState.value = NotificationSyncState.Ready + } + is BaseResult.Error -> fail(result.message) + } + } - when (val result = updateNotiSettingUseCase(newStates)) { - is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } - is BaseResult.Error -> { - Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: $type, enabled: $enabled") - // 실패 시 스위치 원상복구 - _notificationStates.value = originalStates - _toastMessage.value = result.message + private suspend fun readStates(expected: NotificationState? = null): Boolean { + return when (val result = getNotiStateUseCase()) { + is BaseResult.Success -> { + val states = result.data + if (states.count { it.type == NotificationType.WORKOUT_REMINDER } != 1 || + states.count { it.type == NotificationType.CHAT_MESSAGE } != 1 || + (expected != null && states.none { it.type == expected.type && it.enabled == expected.enabled }) + ) { + fail("알림 설정을 확인하지 못했습니다.") + false + } else { + serverStates = states + publishStates() + true } } + is BaseResult.Error -> { + fail(result.message) + false + } } } + private fun publishStates() { + // OS 권한은 표시만 바꾸고, 확인된 서버 값은 별도로 보존합니다. + _notificationStates.value = if (_hasDevicePermission.value) serverStates else serverStates.map { it.copy(enabled = false) } + } + + private fun fail(message: String?) { + _syncState.value = NotificationSyncState.Error + _toastMessage.value = message ?: "알림 설정을 확인하지 못했습니다." + } + fun onPermissionRequestHandled() { _permissionRequestTrigger.value = false } diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml index 235dd295..a247545f 100644 --- a/feature/profile/src/main/res/values/strings.xml +++ b/feature/profile/src/main/res/values/strings.xml @@ -101,6 +101,7 @@ 알림이 활성화되었습니다. 알림을 받으려면 앱 설정에서 권한을 허용해주세요. 알림이 비활성화되었습니다. + 알림 설정을 확인하지 못했습니다. 차단 해제 아직 차단된 계정이 없어요 diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt index 5bdbe266..f20cb6c4 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/ProfileEditViewModelTest.kt @@ -117,6 +117,18 @@ class ProfileEditViewModelTest { coVerify(exactly = 1) { mockGetUserProfileUseCase() } } + @Test + fun `프로필 조회 성공 전에는 loaded 상태와 저장 API를 허용하지 않는다`() = + runTest { + coEvery { mockGetUserProfileUseCase() } returns BaseResult.Success(sampleProfile) + createViewModel() + assertThat(viewModel.isLoaded.value).isFalse() + viewModel.completeEditProfile() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isTrue() + coVerify(exactly = 0) { mockEditProfileUseCase(any(), any(), any()) } + } + @Test fun `getProfile - 성공 시 initProfile이 업데이트된다`() = runTest { @@ -145,6 +157,7 @@ class ProfileEditViewModelTest { // Then assertThat(awaitItem()).isEqualTo(ProfileEditErrorType.LOAD_FAILED) + assertThat(viewModel.isLoaded.value).isFalse() } } @@ -336,6 +349,37 @@ class ProfileEditViewModelTest { } } + @Test + fun `소개를 모두 지우면 빈 문자열을 저장하고 재조회한 빈 입력을 로드한다`() = + runTest { + coEvery { mockGetUserProfileUseCase() } returnsMany + listOf(BaseResult.Success(sampleProfile), BaseResult.Success(sampleProfile.copy(bio = ""))) + coEvery { + mockEditProfileUseCase(sampleProfile.nickname, sampleProfile.gender, "") + } returns BaseResult.Success(Unit) + createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.introduction.value).isEqualTo(sampleProfile.bio) + + viewModel.updateIntroduction("") + viewModel.editResult.test { + viewModel.completeEditProfile() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(awaitItem()).isTrue() + } + coVerify(exactly = 1) { mockEditProfileUseCase(sampleProfile.nickname, sampleProfile.gender, "") } + coVerify(exactly = 0) { mockAddProfileImageUseCase(any()) } + + viewModel.updateIntroduction("저장하지 않은 소개") + viewModel.getProfile() + assertThat(viewModel.isLoaded.value).isFalse() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isTrue() + assertThat(viewModel.initProfile.value?.bio).isEqualTo("") + assertThat(viewModel.introduction.value).isEmpty() + coVerify(exactly = 2) { mockGetUserProfileUseCase() } + } + @Test fun `completeEditProfile - 닉네임 변경 시 중복확인 안하면 NO_DUPLICATE_CHECKED 에러가 emit된다`() = runTest { diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt index ddfcf882..307bbc30 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/mypage/preferredExercise/PreferredExerciseViewModelTest.kt @@ -119,6 +119,21 @@ class PreferredExerciseViewModelTest { coVerify(exactly = 1) { mockGetPreferredExerciseUseCase() } } + @Test + fun `서버 조회 실패는 로드된 빈 목록이나 저장 가능한 상태가 아니다`() = + runTest { + coEvery { mockGetPreferredExerciseTypesUseCase() } returns BaseResult.Success(sampleExerciseTypes) + coEvery { mockGetPreferredExerciseUseCase() } returns BaseResult.Error("ERROR", "조회 실패") + createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.isLoaded.value).isFalse() + viewModel.completePreferredExerciseChanges() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockCreatePreferredExerciseUseCase(any()) } + coVerify(exactly = 0) { mockEditPreferredExerciseUseCase(any()) } + coVerify(exactly = 0) { mockDeletePreferredExerciseUseCase(any()) } + } + @Test fun `fetchInitialData - 성공 시 exerciseUiModels가 업데이트된다`() = runTest { @@ -129,6 +144,7 @@ class PreferredExerciseViewModelTest { testDispatcher.scheduler.advanceUntilIdle() val uiModels = viewModel.exerciseUiModels.value + assertThat(viewModel.isLoaded.value).isTrue() assertThat(uiModels).isNotNull() assertThat(uiModels).hasSize(2) assertThat(uiModels!!.first { it.exercise.exerciseTypeId == 1L }.isSelected).isTrue() diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt index 3804fe5f..a1944d14 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/setting/NotificationViewModelTest.kt @@ -1,6 +1,5 @@ package com.project200.undabang.profile.setting -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState @@ -11,6 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,33 +22,29 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) class NotificationViewModelTest { @get:Rule val mockkRule = MockKRule(this) - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @MockK - private lateinit var mockGetNotiStateUseCase: GetNotificationStateUseCase + private lateinit var getState: GetNotificationStateUseCase @MockK - private lateinit var mockUpdateNotiSettingUseCase: UpdateNotificationStateUseCase - - private lateinit var viewModel: NotificationViewModel - - private val testDispatcher = StandardTestDispatcher() + private lateinit var updateState: UpdateNotificationStateUseCase - private val sampleNotificationStates = + private val dispatcher = StandardTestDispatcher() + private val original = listOf( NotificationState(NotificationType.WORKOUT_REMINDER, true), NotificationState(NotificationType.CHAT_MESSAGE, false), + NotificationState(NotificationType.UNKNOWN, true), ) @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + Dispatchers.setMain(dispatcher) + coEvery { getState() } returns BaseResult.Success(original) } @After @@ -56,168 +52,115 @@ class NotificationViewModelTest { Dispatchers.resetMain() } - private fun createViewModel(): NotificationViewModel { - return NotificationViewModel( - getNotiStateUseCase = mockGetNotiStateUseCase, - updateNotiSettingUseCase = mockUpdateNotiSettingUseCase, - ) - } - - @Test - fun `initNotiState - 권한 있을 때 getNotificationState가 호출된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { mockGetNotiStateUseCase() } - assertThat(viewModel.notificationStates.value).isEqualTo(sampleNotificationStates) - } - - @Test - fun `initNotiState - 권한 없을 때 모든 알림이 비활성화된 기본 상태가 설정된다`() = - runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - val states = viewModel.notificationStates.value - assertThat(states).hasSize(2) - assertThat(states.all { !it.enabled }).isTrue() - } - - @Test - fun `initNotiState - API 에러 시 toastMessage가 설정된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Error("ERROR", "로드 실패") - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - assertThat(viewModel.toastMessage.value).isEqualTo("로드 실패") - } - - @Test - fun `updateNotiStateByPermission - 권한 거부 시 모든 알림이 비활성화된다`() = - runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.updateNotiStateByPermission(isGranted = false) - - val states = viewModel.notificationStates.value - assertThat(states.all { !it.enabled }).isTrue() - } - @Test - fun `updateNotiStateByPermission - 권한 거부에서 허용으로 변경 시 대기 중인 설정이 적용된다`() = + fun `조회가 성공한 뒤에만 ready 상태가 된다`() = runTest { - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - assertThat(viewModel.permissionRequestTrigger.value).isTrue() - - viewModel.updateNotiStateByPermission(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { mockUpdateNotiSettingUseCase(any()) } + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Loading) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value).isEqualTo(original) } @Test - fun `onSwitchToggled - 권한 없이 활성화 시도 시 권한 요청 트리거가 발생한다`() = + fun `권한 거부 중에도 실제 서버 조회가 실패하면 OFF 성공 화면이 아니다`() = runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - - assertThat(viewModel.permissionRequestTrigger.value).isTrue() + coEvery { getState() } returns BaseResult.Error("ERROR", "로드 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.notificationStates.value).isEmpty() + assertThat(model.toastMessage.value).isEqualTo("로드 실패") } @Test - fun `onSwitchToggled - 권한 있을 때 updateSetting이 호출된다`() = + fun `필수 알림 행이 없으면 기본 OFF로 대체하지 않는다`() = runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { mockUpdateNotiSettingUseCase(any()) } + coEvery { getState() } returns BaseResult.Success(original.filterNot { it.type == NotificationType.CHAT_MESSAGE }) + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) } @Test - fun `updateSetting - 성공 시 UI가 업데이트된다`() = + fun `PATCH 완료와 재조회 전에는 낙관적 checked 상태나 다음 쓰기를 허용하지 않는다`() = runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Success(Unit) - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - val chatMessageState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE } - assertThat(chatMessageState?.enabled).isTrue() + val patch = CompletableDeferred>() + coEvery { updateState(any()) } coAnswers { patch.await() } + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + model.onSwitchToggled(NotificationType.WORKOUT_REMINDER, false) + dispatcher.scheduler.runCurrent() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Saving) + assertThat(model.notificationStates.value).isEqualTo(original) + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + coEvery { getState() } returns + BaseResult.Success( + original.map { + if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it + }, + ) + patch.complete(BaseResult.Success(Unit)) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + assertThat(model.notificationStates.value.first { it.type == NotificationType.CHAT_MESSAGE }.enabled).isTrue() } @Test - fun `updateSetting - 실패 시 UI가 원상복구되고 토스트 메시지가 설정된다`() = + fun `PATCH 실패 또는 재조회 불일치는 settled 성공이 아니다`() = runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - coEvery { mockUpdateNotiSettingUseCase(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") - - viewModel = createViewModel() - viewModel.initNotiState(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - val originalState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled - - viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) - testDispatcher.scheduler.advanceUntilIdle() - - val revertedState = viewModel.notificationStates.value.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled - assertThat(revertedState).isEqualTo(originalState) - assertThat(viewModel.toastMessage.value).isEqualTo("업데이트 실패") + coEvery { updateState(any()) } returns BaseResult.Error("ERROR", "업데이트 실패") + val model = NotificationViewModel(getState, updateState) + model.initNotiState(true) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) + assertThat(model.toastMessage.value).isEqualTo("업데이트 실패") + assertThat(model.notificationStates.value).isEqualTo(original) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Error) } @Test - fun `onPermissionRequestHandled - 트리거가 리셋된다`() = + fun `권한 거부는 화면만 OFF로 만들고 서버 설정을 수정하지 않는다`() = runTest { - viewModel = createViewModel() - viewModel.initNotiState(isGranted = false) - - viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, true) - assertThat(viewModel.permissionRequestTrigger.value).isTrue() - - viewModel.onPermissionRequestHandled() - - assertThat(viewModel.permissionRequestTrigger.value).isFalse() + val model = NotificationViewModel(getState, updateState) + model.initNotiState(false) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value.all { !it.enabled }).isTrue() + assertThat(model.syncState.value).isEqualTo(NotificationSyncState.Ready) + coVerify(exactly = 0) { updateState(any()) } + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + assertThat(model.notificationStates.value).isEqualTo(original) } @Test - fun `updateNotiStateByPermission - 초기화 전 호출 시 initNotiState가 호출된다`() = + fun `권한 요청은 처리 후 초기화되고 허용 후 해당 type만 저장한다`() = runTest { - coEvery { mockGetNotiStateUseCase() } returns BaseResult.Success(sampleNotificationStates) - - viewModel = createViewModel() - viewModel.updateNotiStateByPermission(isGranted = true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 1) { mockGetNotiStateUseCase() } + coEvery { updateState(any()) } returns BaseResult.Success(Unit) + val enabled = original.map { if (it.type == NotificationType.CHAT_MESSAGE) it.copy(enabled = true) else it } + val model = NotificationViewModel(getState, updateState) + model.updateNotiStateByPermission(false) + dispatcher.scheduler.advanceUntilIdle() + model.onSwitchToggled(NotificationType.CHAT_MESSAGE, true) + assertThat(model.permissionRequestTrigger.value).isTrue() + model.onPermissionRequestHandled() + assertThat(model.permissionRequestTrigger.value).isFalse() + coEvery { getState() } returnsMany listOf(BaseResult.Success(original), BaseResult.Success(enabled)) + model.updateNotiStateByPermission(true) + dispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { updateState(listOf(NotificationState(NotificationType.CHAT_MESSAGE, true))) } + assertThat(model.notificationStates.value).isEqualTo(enabled) } }