diff --git a/.github/workflows/android-maestro.yml b/.github/workflows/android-maestro.yml index dc005445..8c53a93d 100644 --- a/.github/workflows/android-maestro.yml +++ b/.github/workflows/android-maestro.yml @@ -16,6 +16,7 @@ on: - regression - all - ci + - offline permissions: contents: read @@ -68,7 +69,7 @@ jobs: cancel-in-progress: false queue: max env: - MAESTRO_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.suite || 'ci' }} + MAESTRO_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.suite || 'all' }} steps: - uses: actions/checkout@v4 with: diff --git a/.maestro/README.md b/.maestro/README.md index 54d3d66a..f7ec760b 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -101,20 +101,20 @@ python3 .maestro/scripts/run-with-session.py \ python3 .maestro/scripts/run-tests.py --suite smoke --device emulator-5554 \ --apk app/build/outputs/apk/e2e/app-e2e.apk python3 .maestro/scripts/run-tests.py --suite regression --device emulator-5554 -python3 .maestro/scripts/run-tests.py --suite ci --device emulator-5554 -python3 .maestro/scripts/run-tests.py --suite all --device emulator-5554 +python3 .maestro/scripts/run-tests.py --suite ci --device emulator-5554 --apk app/build/outputs/apk/e2e/app-e2e.apk +python3 .maestro/scripts/run-tests.py --suite all --device emulator-5554 --apk app/build/outputs/apk/e2e/app-e2e.apk python3 .maestro/scripts/run-tests.py --suite login --device emulator-5554 ``` -`--apk`를 생략하면 설치된 e2e 앱을 사용합니다. 러너는 **앱 데이터를 지우기 전에** 준비 컴포넌트와 `run-as` 가능 여부를 확인합니다. 일반 debug/release 앱을 대신 초기화하지 않습니다. protected suite는 비밀이 없으면 기기에 손대기 전에 실패하며 일부 비로그인 테스트만 실행해 성공으로 처리하지 않습니다. +`--apk`를 생략하면 일반 suite는 설치된 e2e 앱을 사용하지만 두 기기 채팅을 포함한 `chatting`/`ci`/`all`은 양쪽에 같은 APK를 설치하므로 `--apk`가 필수입니다. 러너는 **앱 데이터를 지우기 전에** 준비 컴포넌트와 `run-as` 가능 여부를 확인합니다. 일반 debug/release 앱을 대신 초기화하지 않습니다. protected suite는 비밀이 없으면 기기에 손대기 전에 실패하며 일부 비로그인 테스트만 실행해 성공으로 처리하지 않습니다. 각 플로우의 순서는 다음과 같습니다: 1. 앱 강제 종료와 `pm clear`로 이전 로컬 인증·Room 캐시·권한을 지웁니다. 2. 보호 플로우는 앱 private 파일에 `prepare` 요청을 stdin으로 전달합니다. 요청은 `authState`, `memberId`, `fixtureStartDate`를 포함합니다. Intent extra/deep link에는 인증 정보가 없습니다. 3. e2e Activity가 DEV discovery와 가져온 endpoint/client를 검증하고 실제 토큰을 갱신합니다. 서버 등록 상태/member ID를 확인한 뒤 예약 운동 기록을 정리합니다. `fixture:true` 기능은 선택한 Hilt handler의 prepare도 수행합니다. `ready` 결과가 아니면 Maestro를 실행하지 않습니다. -4. 기능의 `before_flow` hook, 실제 UI 플로우, `after_flow` hook 순서로 실행합니다. Maestro 환경에는 인증/CI 비밀을 상속하지 않습니다. 기기 한국 시간의 운동 변수, ASCII `E2E_RUN_ID`, 명시한 비밀 없는 기능 변수만 전달합니다. -5. 성공, 단언 실패, 타임아웃, 준비 실패, 처리 가능한 중단 신호 모두 `finally`에서 `cleanup`을 요청합니다. 실제 인증/member 확인 후 기능 cleanup을 먼저 수행하고 기존 운동 정리·세션 제거를 수행합니다. 성공하면 앱 데이터를 지웁니다. 테스트나 정리 중 하나라도 실패하면 종료 코드는 **1**입니다. +4. 기능의 `before_flow` hook, 실제 UI 플로우, `after_flow` hook 순서로 실행합니다. driver 기능은 선언한 실제 단계의 실행 순서를 소유합니다. Maestro 환경에는 인증/CI 비밀을 상속하지 않습니다. 기기 한국 시간의 운동 변수, ASCII `E2E_RUN_ID`, 명시한 비밀 없는 기능 변수만 전달합니다. +5. 성공, 단언 실패, 타임아웃, 준비 실패, 처리 가능한 중단 신호 모두 `finally`에서 `cleanup`을 요청합니다. 오프라인 driver는 실제 네트워크를 먼저 복원합니다. 실제 인증/member 확인 후 기능 cleanup을 먼저 수행하고 기존 운동 정리·세션 제거를 수행합니다. 복원이나 cleanup이 실패하면 앱 private 복구 상태를 보존하고 후속 기능 실행을 중단합니다. 테스트나 정리 중 하나라도 실패하면 종료 코드는 **1**입니다. 기능 prepare 전에 앱 private `maestro-feature-recovery.json`에 feature/flow/runId/memberId ownership을 기록합니다(인증/parameters는 저장하지 않음). 기능 handler는 cleanup이 성공해도 snapshot과 ownership 증거를 삭제하지 않습니다. 공통 coordinator의 기능 cleanup·운동 정리·세션 제거가 모두 성공한 뒤 호스트의 최종 `pm clear`가 성공할 때 앱 private 증거를 함께 제거합니다. @@ -138,12 +138,29 @@ UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통 |`flows/exercise-form-entry.yaml`|`regression`, `requires-login`|`regression`, `all`|실제 기록 폼 진입과 필드 표시| |`flows/exercise-crud.yaml`|`regression`, `requires-login`|`regression`, `all`|필수값 누락, UI 생성/조회/수정, 재실행 후 영속성, UI 삭제와 전체 목록 부재 검사| |`flows/login-screen.yaml`|`smoke`|`login`, `smoke`, `ci`, `all`|clearState 뒤 실제 로그인 화면/버튼| +|`flows/offline-exercise-*.yaml`|호스트 driver 필요|`offline`, `ci`, `all`|온라인 생성·캘린더/목록/상세 로드, 실제 단절·재실행 캐시, 별도 초기화한 캐시 미스| +|`flows/offline-timers-*.yaml`|호스트 driver 필요|`offline`, `ci`, `all`|고유 타이머 오프라인 생성/수정/삭제·재실행, 재접속 폼/최종 값, 독립 서버 GET| `login-screen`은 세션을 지우므로 직접 Maestro 전체 실행의 `config.yaml`에서도 마지막입니다. 호스트 러너는 앞 플로우의 로그인 상태에 의존하지 않고 매번 복원합니다. 직접 실행한 뒤에는 다시 수동 로그인해야 합니다. ### 기능 descriptor와 공유 계약 -`--suite ci`는 smoke-tabs → 현재 트리의 모든 `.maestro/suites/.json`(파일명 순, 각 flows 선언 순) → login-screen입니다. `all`은 여기에 기존 exercise 회귀 두 개를 포함합니다. **`regression`은 기존 운동 두 개만** 실행하며 전체 기능 회귀의 별칭이 아닙니다. `--suite `는 해당 descriptor만 실행합니다. descriptor가 없으면 그 기능을 실행했다고 주장하지 않으며 잘못된 descriptor/빈 flows/없는 파일/중복 흐름은 실패합니다. 기능 상세는 `.maestro/README-.md`를 참조합니다. +자동 trusted dev PR/push는 **`--suite all`**을 선택합니다. 순서는 smoke-tabs → exercise 회귀 두 개 → 모든 `.maestro/suites/.json`(파일명 순, 각 flows 선언 순) → login-screen입니다. 수동 `ci`는 exercise 회귀 두 개만 제외합니다. **`regression`은 기존 운동 두 개만** 실행하며 전체 기능 회귀의 별칭이 아닙니다. `--suite `는 해당 descriptor만 실행합니다. 잘못된 descriptor/빈 flows/없는 파일/중복 흐름은 실패합니다. 채팅과 오프라인은 YAML 단순 반복이 아니라 실제 driver 경로로 실행합니다. + +현재 `all`은 다음 **33개 결과 case**를 선택합니다(최대 API34). 아래 이름은 모두 실제 `.maestro/flows/<이름>.yaml`입니다. + +|실행 경로|선택한 case 이름| +|---|---| +|공통 시작|`smoke-tabs`, `exercise-form-entry`, `exercise-crud`| +|`chatting` 두 계정/두 기기 driver|`chatting-send`, `chatting-receive`, `chatting-list-refresh`, `chatting-history`| +|`feed` 공통 lifecycle + hook|`feed-post-crud`, `feed-like`, `feed-comment`, `feed-photo`, `feed-form-validation`| +|`matching` 공통 lifecycle + hook|`matching-place-crud`, `matching-place-validation`, `matching-member-to-chat`| +|`offline` 네트워크/서버 관찰 driver|`offline-exercise-create`, `offline-exercise-cached`, `offline-exercise-missing`, `offline-timers-online`, `offline-timers-mutate`, `offline-timers-relaunch`, `offline-timers-after-reconnect`, `offline-timers-reconnect`| +|`profile` 공통 lifecycle|`profile-edit`, `profile-edit-validation`, `profile-preferred-exercise`, `profile-notification`| +|`timer` 공통 lifecycle|`timer-simple-preset-crud`, `timer-simple-run`, `timer-custom-crud`, `timer-custom-run`, `timer-form-validation`| +|공통 마지막|`login-screen`| + +최상위 YAML 중 독립 결과 case가 아닌 `chatting-open`, `chatting-ready`, `chatting-away`는 채팅 driver가 실제 A/B 단계에서 실행합니다. 다른 누락 YAML은 없습니다. 오프라인 helper 8개와 공통 subflow는 해당 실제 단계가 호출합니다. 앞선 driver 실패나 안전하지 않은 정리/중단으로 후속 case를 실행하지 못하면 JUnit 실패를 남깁니다. 선택 목록만으로 실행 통과를 주장하지 않습니다. descriptor 스키마(모든 필드 필수): @@ -152,8 +169,8 @@ descriptor 스키마(모든 필드 필수): ``` - `fixture:true`: `MaestroFeatureFixture`의 `prepare(MaestroFeatureRequest): Map`, `cleanup(MaestroFeatureRequest)`를 같은 요청으로 호출합니다. request는 `feature`, `flow`, `runId`, 검증된 `memberId`, private `parameters: JSONObject`입니다. 비어 있는 공통 Hilt multibinding으로 기능 없는 기준 트리도 지원하며, 요청한 handler가 없으면 실패합니다. -- `.maestro/scripts/features/.py`가 있으면 `driver:null`이어도 `before_flow(core, device, flow, variables, credentials, run_id) -> dict`와 `after_flow(...) -> None` hook을 자동 사용합니다. hook 실패도 backend cleanup을 거칩니다. `E2E_RUN_ID`는 플로우당 16자리 UUID hex이며 prepare/UI/cleanup 간 동일합니다. -- `driver:""`는 `run_suite(args, core)` 전체 수명 소유자입니다. 실제 두 기기 채팅이 사용하며 양 기기 인증·locale·ARM ABI 설치 검증·정리를 직접 수행합니다. `--peer-device`로 별도 serial을 주거나 driver가 같은 AVD의 읽기 전용 두 번째 emulator를 만듭니다. 반환 결과는 선언한 모든 `(flow_name, seconds, [fixed_error_codes])`여야 하며 누락/잘못된 결과는 실패입니다. +- `.maestro/scripts/features/.py`가 있으면 `driver:null`이어도 `before_flow(core, device, flow, variables, credentials, run_id) -> dict`와 `after_flow(...) -> None` hook을 자동 사용합니다. hook 실패도 backend cleanup을 거칩니다. 일반 플로우의 `E2E_RUN_ID`는 16자리, 오프라인 시나리오는 32자리 UUID hex이며 prepare/UI/cleanup 간 동일합니다. +- `driver:""`는 `run_suite(args, core)` 전체 수명 소유자입니다. 채팅은 양 기기 인증·locale·ARM ABI 설치 검증·정리를 직접 수행합니다. `--peer-device`로 별도 serial을 주거나 같은 AVD의 읽기 전용 두 번째 emulator를 만듭니다. 오프라인은 실제 네트워크 전환과 단계 간 독립 서버 관찰을 수행합니다. 반환 결과는 선언한 모든 `(flow_name, seconds, [fixed_error_codes])`여야 하며 누락/잘못된 결과는 실패입니다. - 공통 Python API: `read_credentials(prefix="MAESTRO_")`(peer는 `MAESTRO_PEER_`), `Device.bootstrap(operation, credentials=None, timeout=300, feature=None, flow=None, run_id=None, parameters=None) -> dict`, `run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None, variables=None)`. feature metadata 네 필드는 prepare/cleanup에 함께 전달합니다. driver의 `args.output`은 공통 결과 `Path`입니다. - `requiredEnvironment` 값은 명시한 이름 그대로 private parameters에만 전달합니다. 비밀 변수나 auth/token/FCM 반환은 거절합니다. 모든 가족을 하나의 계정 잠금 안에서 순차 실행하고, 실제 선택한 플로우 이름의 JUnit을 하나로 합칩니다. 입력 preflight 실패도 선택 플로우를 실패로 기록하며 smoke로 대체하지 않습니다. @@ -163,6 +180,29 @@ CRUD 입력은 ASCII 제목과 숫자를 사용합니다. 러너는 준비 완 현재 viewport의 `assertNotVisible` 하나로 끝내지 않습니다. 캘린더 헤더부터 화면 20%의 느리고 겹치는 swipe(`duration: 2000ms`, 최대 200회)마다 대상 제목이 없는지 확인하며, 기록이 남아 있으면 마지막 실제 행의 시간 TextView 접근성 레이블 `'운동 기록 목록 마지막 항목: '`에 도달해야 통과합니다. 빈 목록은 정확한 날짜의 성공한 빈 응답 레이블이 필요합니다. 제한된 횟수 안에 끝에 도달하지 못하거나 요청·목록 검사에 실패하면 실패합니다. 이전 날짜·초기 빈 화면을 성공으로 처리하지 않습니다. 호스트의 플로우 제한(기본 900초)을 넘겨도 실패하며 자동 재시도하지 않습니다. +### 자동 전체 CI에 포함되는 오프라인 회귀(#617) + +이 suite는 #609의 세션/private-file 전송과 #610의 DEV 계정·CI 신뢰 경계를 재사용합니다. **별도 기능 PR #584 운동 캐시, #588 업로드, #589 동기화 실행 경로와 api-server #574/#575의 실제 배포가 필요합니다.** #617은 dev 기반 독립 PR이며 #584 구현을 복사하지 않습니다. 현재 dev의 운동 repository는 원격 조회만 수행하고 타이머 쓰기는 로컬 pending만 생성하므로 오프라인 캐시·실제 서버 업로드 검사가 실패할 수 있습니다. 서버 conflict/editedAt 응답 형식을 추측하거나 테스트가 업로드를 대신하지 않습니다. + +**BLOCKED — pending 행이 남아 있는 동안 성공한 원격 목록을 적용해도 변경이 보존되는지 증명하는 기준은 아직 충족하지 못합니다.** 현재 `TimerRepositoryImpl.getCustomTimerList()`는 성공 GET 뒤 `TimerLocalDataSource.replaceSyncedCustomTimers()`를 호출하지만 NETWORK_ERROR도 로컬 fallback 성공으로 반환합니다. `TimerListViewModel`과 e2e 준비 경로에는 실제 적용 시점과 그때의 pending 상태를 연결하는 관찰 계약이 없습니다. #588/#589가 업로드를 먼저 완료할 수도 있으므로 실제 uploader/목록 적용의 pending-before/after·성공 GET·업로드 순서를 관찰하거나 안전하게 제어하는 계약이 필요합니다. read-only pending 조회 함수만으로 이 순서를 증명할 수 없습니다. 아래 최종 값 검사는 이 기준이나 #617 전체 완료의 증거가 아닙니다. + +```bash +python3 .maestro/scripts/run-with-session.py \ + "$HOME/.config/undabang-e2e/maestro-session.json" --suite offline \ + --device emulator-5554 --apk app/build/outputs/apk/e2e/app-e2e.apk +``` + +- `.maestro/suites/offline.json`은 실제 실행하는 8개 단계 YAML을 선언하고 `features/offline.py`가 세 시나리오로 묶습니다. `all`과 `ci`는 자동 탐색하며 trusted dev CI는 `all`을 사용합니다. 수동 `offline`도 같은 driver를 사용합니다. 단계는 `.maestro/flows/offline-*.yaml`, 공용 helper는 `.maestro/offline/`에 있습니다. 각 단계는 호스트의 네트워크·세션·정리 순서를 필요로 하므로 단독 Maestro 실행은 지원하지 않습니다. +- DEV Firebase Remote Config의 **`offline_cache_enabled=true`를 실제로 게시**해야 합니다. 준비는 기존 앱의 fetch/activate와 `FeatureFlagDataSource`를 사용해 값을 확인합니다. 기본값은 false이며 e2e override는 없습니다. 꺼져 있으면 `offline-suite-requires-enabled-cache-flag`로 실패합니다. +- 기기의 `wifi_on`, `mobile_data`, `airplane_mode_on`이 명확한 0/1이고 초기 기본망이 있어야 합니다. 러너는 Wi-Fi와 모바일 데이터를 실제 `svc`로 끈 뒤 `dumpsys connectivity`의 **현재** `Active default network: none`을 기다립니다. 설정값만 바뀌거나 Ethernet/VPN 등 기본망이 남으면 실패합니다. API30 또는 전체 CI의 최대 API34에서 보고서 형식을 확인해야 하며 지원하지 않는 형식은 실패합니다. 가짜 offline 모드로 대체하지 않습니다. +- 운동 기록은 기존 UI 폼으로 만듭니다. 실제 서버 목록/상세에서 고유 제목과 시작/종료 시각을 확인한 뒤, 온라인에서 로드한 캘린더의 해당 날짜 표시·목록·상세를 단절 후 새 프로세스에서 다시 단언합니다. 별도 초기화 시나리오는 운동 화면을 미리 열지 않고 단절해 해당 날짜 목록의 실제 `NETWORK_ERROR` 완료 레이블을 요구합니다. 점수/정책 조회의 공통 Toast나 초기 빈 목록은 캐시 미스 검증으로 인정하지 않습니다. +- 타이머는 `maestro-offline-<32자리 UUID>-update/delete` 두 개를 UI로 만들어 **서버 GET에 실제 나타날 때까지** 기다립니다. 단절 후 하나를 `-updated`/75초로 수정하고 다른 하나는 삭제하며, `-create`/45초를 새로 만듭니다. 재실행 뒤 제목·스텝·시간·전체 목록의 삭제 부재를 확인합니다. 심플 타이머와 일반 타이머는 변경하지 않습니다. +- 재접속 직전 120초와 `-draft` 제목을 입력하되 저장하지 않습니다. 연결 복귀 직후에도 이 폼을 확인하고, 폼을 버린 뒤 새 타이머 목록 화면에 표시되는 저장값을 확인합니다. 이는 재접속 결과 검사이며 성공한 원격 목록 적용이나 그때의 pending 상태를 증명하지 않습니다. 다시 미저장 폼을 열어 둔 상태에서 독립 서버 관찰을 완료한 뒤에도 입력값이 그대로인지 검사합니다. +- 서버 관찰은 **e2e 전용 관찰 코드가 `ApiService`를 직접 GET**하며 Room/Repository fallback을 사용하지 않습니다. 동일 UUID의 정확히 두 타이머, 45초/75초의 이름·순서 0 스텝 하나, 원제목·삭제 제목·미저장 draft·중복 부재를 확인합니다. 최대 120초 안에 실제 업로드가 없으면 `offline-suite-requires-real-server-upload`로 실패합니다. 관찰은 원 ownership과 저장된 member ID를 확인하고 DEV discovery·현재 인증의 신뢰 검증, 실제 토큰 갱신, 부작용 없는 등록 API의 member ID 비교를 거칩니다. 회원 교체 시 Room을 지우는 `AuthRepository.checkIsRegistered()`를 호출하지 않으며 인증을 재가져오거나 앱을 강제 종료하지 않습니다. +- 각 시나리오의 `finally`는 **원래 Wi-Fi/모바일 설정과 실제 기본망 상태를 먼저 복원·확인**한 뒤 cleanup을 요청합니다. 오프라인 cleanup은 인증 변경 전에 feature/flow/runId/memberId ownership과 저장된 회원을 확인합니다. 유효한 현재 DEV 인증은 유지하고, 토큰이 없으면 신뢰 검증한 인증의 토큰만 복원합니다. 실제 갱신과 직접 등록 API의 동일 회원 확인 뒤에만 이번 UUID 운동/커스텀 타이머를 상세 재확인 후 삭제합니다. 기능 정리와 공통 운동 정리가 모두 성공한 뒤 세션·ownership을 완료하고 호스트가 최종 `pm clear`합니다. 관찰/cleanup 실패는 토큰만 지워 fail-closed로 처리하며 로컬 회원·Room pending·private recovery marker를 유지합니다. 복원 실패면 서버 정리도 하지 않습니다. 복원/cleanup 실패는 다음 시나리오와 기능을 중단시킵니다. 실패·SIGINT/SIGTERM도 같은 경로를 사용합니다. SIGKILL·기기 단절·전체 job 강제 종료 뒤 복원/정리는 보장할 수 없습니다. 남은 ownership은 원 runId/flow와 날짜 parameters·동일 계정으로 복구해야 합니다. + +이 경로는 Room fixture 삽입, sync 상태 강제 변경, Worker 대리 업로드를 하지 않습니다. 선택자/네트워크/서버/플래그 준비가 안 되면 실패를 남기며 skip으로 통과시키지 않습니다. + ### 수동 UI 개발(격리 CI와 다른 경로) debug/e2e APK를 직접 설치하고 **수동 로그인한 기기**에서 `maestro test`를 실행할 수도 있습니다. 이 경로에는 호스트 준비/정리·CI 계정 검증이 없습니다. 세션이 만료되면 수동 로그인해야 합니다. CRUD는 필요한 제목/날짜 변수를 직접 공급해야 하므로 안전한 정리까지 필요한 실행은 호스트 러너를 권장합니다. @@ -185,18 +225,18 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em 프로필 smoke 플로우는 작은 화면에서 화면 밖 콘텐츠를 화면 누락으로 오인하지 않도록 `preferred_exercise_tv`까지 스크롤한 뒤 `선호 운동` 표시를 단언합니다. -## CI: 신뢰된 dev push/PR 기능 suite와 수동 regression +## CI: 신뢰된 dev push/PR 전체 suite와 수동 진단 `.github/workflows/android-maestro.yml`은 기존 `android-dev-ci.yml`/릴리스 CI와 분리되어 있습니다. |트리거|ref/신뢰 조건|suite/결과| |---|---|---| -|`push`|`refs/heads/dev`만 허용|`ci`(smoke + 현재 트리의 실제 기능)| +|`push`|`refs/heads/dev`만 허용|`all`(smoke + exercise 회귀 + 모든 설치 기능 + offline + login)| |`push`|다른 branch/tag ref|트리거 필터에서 제외, 비밀 미제공| -|`pull_request`|base가 `dev`, 같은 저장소 head, 작성자 association `OWNER`/`MEMBER`/`COLLABORATOR`/`CONTRIBUTOR`|`ci`(smoke + 현재 트리의 실제 기능)| +|`pull_request`|base가 `dev`, 같은 저장소 head, 작성자 association `OWNER`/`MEMBER`/`COLLABORATOR`/`CONTRIBUTOR`|`all`(smoke + exercise 회귀 + 모든 설치 기능 + offline + login)| |`pull_request`|base가 `dev`, fork head 또는 비신뢰 작성자|비밀 없는 거절 job 실패, 인증 job 미실행| |`pull_request`|다른 base branch|트리거 필터에서 제외, 비밀 미제공| -|`workflow_dispatch`|ref `refs/heads/dev`, 선택한 suite|`regression`, `all`, `ci`| +|`workflow_dispatch`|ref `refs/heads/dev`, 선택한 suite|`regression`, `all`, `ci`, `offline`| |`workflow_dispatch`|다른 ref|비밀 없는 거절 job 실패, 인증 job 미실행| - 인증 suite를 실행하지 않은 skip은 인증 통과가 아닙니다. 거절 job은 미실행 상태를 명시하며 **실패**합니다. `pull_request_target`은 사용하지 않습니다. @@ -260,7 +300,9 @@ CI는 실패해도 `always()`로 상태 파일과 안전한 XML/log/JSON/PNG만 ## 검증 상태와 남은 외부 준비 -사용자 보고 run `39007766770`에서는 고정 keystore 복원과 APK 빌드가 성공했고 smoke·피드·타이머·로그인은 통과했습니다. matching 세 플로우는 모두 `matching_map_ready` 대기에서 실패했으며 SDK callback의 종류·코드는 아직 수집되지 않았습니다. 이번 SDK 진단 변경은 정적 확인만 했고 로컬 빌드·테스트·기기 실행은 수행하지 않았습니다. 고정 서명 이후에도 실패 원인은 미확정입니다. +사용자 보고 run `39007766770`에서는 고정 keystore 복원과 APK 빌드가 성공했고 smoke·피드·타이머·로그인은 통과했습니다. matching 세 플로우는 모두 `matching_map_ready` 대기에서 실패했으며 SDK callback의 종류·코드는 아직 수집되지 않았습니다. 고정 서명 이후에도 실패 원인은 미확정입니다. + +이번 오프라인 통합의 **로컬 unittest·빌드·lint·formatter·설치·실기기/에뮬레이터·OAuth·Maestro·CI 검증은 실행하지 않았습니다.** 소스/문서 정적 확인과 테스트 작성만 수행했으며 과거 run이나 명령 예시는 이 통합의 실행 성공 증거가 아닙니다. 저장소 루트에서 사용하는 표준 라이브러리 회귀 테스트 명령: @@ -272,7 +314,8 @@ fi ``` `test_run_tests.py`의 로컬 셸 하네스는 실제 adb/기기/계정 없이 stdin 전달, 원격 종료 상태, JSON 검증과 실패 시 export 파일 제거를 검사합니다. 하네스 통과도 실기기 export 증거는 아닙니다. 후속 기기 검증에서는 인증된 세션/member ID 쌍, 비ASCII JSON 왕복과 private 파일 제거를 별도로 확인해야 합니다. -공통 입력 회귀 테스트는 공개 IME metadata의 service/resource 연결, 영어 ASCII subtype 선택, ID 생략/0의 Android hash 계산(UTF-16·overflow·기본값 포함), 모호한 subtype/자동 선택 ID 거절, 다른 enabled IME 보존, 설정 미반영과 물리 기기 거절 경계를 검사합니다. 이 입력 수정의 unittest·에뮬레이터·Maestro 실행도 수행하지 않았습니다. -`test_feature_suites.py`와 `MaestroFeatureFixtureTest`는 descriptor/API 선택, 입력 선검사, 소유권 및 복구 실패 시 clear 중단, hook 실패 후 teardown, driver 누락 결과를 검사합니다. coordinator 회귀 테스트는 기능 cleanup 성공 뒤 운동 정리 또는 세션 제거가 실패하면 recovery 완료/성공 응답을 쓰지 않고, 동일 ownership으로 인증·기능 cleanup·후속 정리를 다시 수행하는지 검사합니다. 기능별 snapshot 보존과 반복 복구는 각 기능 fixture 테스트가 담당하며, 단위 테스트 통과만으로 실제 서버 복구를 검증했다고 보지 않습니다. +공통 입력 회귀 테스트는 공개 IME metadata의 service/resource 연결, 영어 ASCII subtype 선택, ID 생략/0의 Android hash 계산(UTF-16·overflow·기본값 포함), 모호한 subtype/자동 선택 ID 거절, 다른 enabled IME 보존, 설정 미반영과 물리 기기 거절 경계를 검사합니다. +`test_feature_suites.py`는 실제 설치 descriptor의 전체 선택/API, chat/offline driver dispatch, 입력 선검사, 복구 실패 시 clear 중단, hook 실패 후 teardown과 driver 누락 결과를 검사합니다. `features/test_offline.py`는 실제 driver의 8개 단계 실행, 라디오 복원·현재 기본망 판정, 부분 실패·중단·미업로드·복구 실패와 미실행 단계의 실패 보고를 검사합니다. +`MaestroFeatureFixtureTest`와 coordinator 테스트는 handler cleanup 뒤 운동 정리/세션 제거 실패 시 recovery 보존과 동일 ownership 재시도, 파싱 직후 출력 초기화 실패부터 적용하는 오프라인 토큰 전용 실패 정책과 다른 기능의 기존 세션 제거 정책을 검사합니다. `MaestroOfflineRecoveryTest`는 실제 bootstrap·`AuthRepositoryImpl`·회원 Preferences·private recovery 파일·오프라인 fixture를 연결합니다. `SessionDataCleaner.clearAll()` 감지기와 모의 인증/서버 응답으로 삭제 실패와 동일 ownership 재시도, 잘못된 로컬/서버 회원·ownership, 등록 불가·갱신 실패/취소·신뢰 검증 실패에서 pending 증거와 회원 경계가 보존되는지 검사합니다(실제 Room·네트워크 통합 테스트는 아님). 오프라인 fixture 테스트는 직접 서버 응답 값/중복/실패와 UUID 삭제 범위를 검사합니다. 이 테스트들은 작성했지만 실행하지 않았으며, 단위 테스트만으로 실제 서버 업로드/복구나 pending 중 목록 적용을 검증했다고 보지 않습니다. 실기 검증에는 실제 DEV 전용 등록 계정, 유효한 내보낸 세션과 기본 CI 비밀, 선택 기능의 실제 peer/room/지도 입력, 보호된 `maestro-dev` 환경, Firebase/Kakao debug 등록, KVM/ARM translation이 있는 기기가 필요합니다. 일반 smoke 성공은 기능 성공 증거가 아닙니다. 실제 flow 이름별 원격 결과, API33 미디어/권한·알림, 채팅 두 기기, 서버 복구 실패 경로를 확인해야 합니다. prepare/cleanup의 refresh 결과를 GitHub Secret에 되쓰지 않으므로 갱신 토큰 rotation/만료 정책에 따라 다시 내보내기가 필요할 수 있습니다. 영구 인증이나 미검증 password-login을 제공하지 않습니다. diff --git a/.maestro/flows/offline-exercise-cached.yaml b/.maestro/flows/offline-exercise-cached.yaml new file mode 100644 index 00000000..f349f725 --- /dev/null +++ b/.maestro/flows/offline-exercise-cached.yaml @@ -0,0 +1,9 @@ +appId: com.project200.undabang +name: 실제 단절 후 재실행한 캘린더 목록 상세 캐시 +--- +# 호스트가 두 라디오와 현재 기본망의 단절을 확인한 뒤 실행합니다. +- stopApp +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- runFlow: ../offline/assert-exercise-record.yaml diff --git a/.maestro/flows/offline-exercise-create.yaml b/.maestro/flows/offline-exercise-create.yaml new file mode 100644 index 00000000..bb019269 --- /dev/null +++ b/.maestro/flows/offline-exercise-create.yaml @@ -0,0 +1,46 @@ +appId: com.project200.undabang +name: 오프라인 조회용 실제 운동 기록 생성 +--- +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- tapOn: + id: exercise_create_btn +- extendedWaitUntil: + visible: + id: exercise_title_input + timeout: 30000 +- tapOn: + id: exercise_title_input +- inputText: "${EXERCISE_TITLE}" +- hideKeyboard +- runFlow: + file: ../subflows/set-exercise-time.yaml + env: + TIME_BUTTON: exercise_start_time + TIME_HOUR: "${EXERCISE_START_HOUR}" + TIME_MINUTE: "00" +- runFlow: + file: ../subflows/set-exercise-time.yaml + env: + TIME_BUTTON: exercise_end_time + TIME_HOUR: "${EXERCISE_END_HOUR}" + TIME_MINUTE: "00" +- tapOn: + id: exercise_complete +- extendedWaitUntil: + visible: + id: ".*(confirm_btn|exercise_create_btn)" + timeout: 30000 +- runFlow: + when: + visible: + id: confirm_btn + commands: + - tapOn: + id: confirm_btn +- extendedWaitUntil: + visible: + id: exercise_create_btn + timeout: 30000 +- runFlow: ../offline/assert-exercise-record.yaml diff --git a/.maestro/flows/offline-exercise-missing.yaml b/.maestro/flows/offline-exercise-missing.yaml new file mode 100644 index 00000000..77f7f518 --- /dev/null +++ b/.maestro/flows/offline-exercise-missing.yaml @@ -0,0 +1,21 @@ +appId: com.project200.undabang +name: 운동 캐시 없는 첫 조회는 네트워크 오류 +--- +# 별도 pm clear와 인증 뒤 운동 화면을 한 번도 열지 않고 실제 망을 끊습니다. +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- scrollUntilVisible: + element: + id: exercise_list_empty_tv + text: "${EXERCISE_END_DATE.split('.').join('-') + ' 운동 기록 목록 네트워크 오류'}" + direction: DOWN + timeout: 30000 +- assertVisible: + id: exercise_list_empty_tv + text: "${EXERCISE_END_DATE.split('.').join('-') + ' 운동 기록 목록 네트워크 오류'}" +- assertVisible: + id: exercise_create_btn +# 초기 빈 목록을 성공한 빈 캐시로 오인하지 않습니다. +- assertNotVisible: + text: "${EXERCISE_END_DATE.split('.').join('-') + '( 운동 기록 목록|에 운동 기록이 없습니다)'}" diff --git a/.maestro/flows/offline-timers-after-reconnect.yaml b/.maestro/flows/offline-timers-after-reconnect.yaml new file mode 100644 index 00000000..fd69de1c --- /dev/null +++ b/.maestro/flows/offline-timers-after-reconnect.yaml @@ -0,0 +1,22 @@ +appId: com.project200.undabang +name: 접속 복귀 후 폼과 다시 연 목록의 최종 값 +--- +# launchApp/stopApp 없이 직전 폼을 그대로 관찰합니다. +- runFlow: ../offline/assert-timer-draft.yaml +- back +- back +- back +- scrollUntilVisible: + element: + id: timer_btn + direction: UP + timeout: 30000 +# 새 목록 화면을 엽니다. 성공한 원격 GET이나 pending 상태에서의 병합 순서를 증명하지는 않습니다. +- tapOn: + id: timer_btn +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 +- runFlow: ../offline/assert-timer-list.yaml +- runFlow: ../offline/prepare-timer-draft.yaml diff --git a/.maestro/flows/offline-timers-mutate.yaml b/.maestro/flows/offline-timers-mutate.yaml new file mode 100644 index 00000000..e6f48530 --- /dev/null +++ b/.maestro/flows/offline-timers-mutate.yaml @@ -0,0 +1,53 @@ +appId: com.project200.undabang +name: 실제 오프라인 커스텀 타이머 수정 삭제 생성 +--- +- runFlow: ../offline/open-timer-list.yaml +- scrollUntilVisible: + element: + id: custom_timer_add_card + direction: DOWN + timeout: 30000 +- runFlow: + file: ../offline/edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_ORIGINAL_TITLE}" + EXPECTED_TIMER_TIME: "00:45" +- tapOn: + id: custom_timer_form_title_field +- eraseText: 255 +- inputText: "${TIMER_UPDATED_TITLE}" +- hideKeyboard +- runFlow: + file: ../offline/set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_form_step_time_0 + TIMER_MINUTES: "01" + TIMER_SECONDS: "15" +- tapOn: + id: custom_timer_form_submit_btn +- extendedWaitUntil: + visible: + id: custom_timer_detail + timeout: 30000 +- assertVisible: "${TIMER_UPDATED_TITLE}" +- back +- scrollUntilVisible: + element: + text: "${TIMER_DELETE_TITLE}" + direction: DOWN + timeout: 30000 +- tapOn: "${TIMER_DELETE_TITLE}" +- tapOn: + id: custom_timer_menu_btn +- tapOn: 삭제하기 +- assertVisible: "정말 타이머를 삭제할까요[?]" +- tapOn: 확인 +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 +- runFlow: + file: ../offline/create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_CREATE_TITLE}" +- runFlow: ../offline/assert-timer-list.yaml diff --git a/.maestro/flows/offline-timers-online.yaml b/.maestro/flows/offline-timers-online.yaml new file mode 100644 index 00000000..9746d190 --- /dev/null +++ b/.maestro/flows/offline-timers-online.yaml @@ -0,0 +1,13 @@ +appId: com.project200.undabang +name: 동기화될 전용 커스텀 타이머 두 개를 UI로 생성 +--- +- runFlow: ../offline/open-timer-list.yaml +- runFlow: + file: ../offline/create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_ORIGINAL_TITLE}" +- runFlow: + file: ../offline/create-timer.yaml + env: + NEW_TIMER_TITLE: "${TIMER_DELETE_TITLE}" +# 호스트는 실제 서버 GET에서 두 이름과 45초 스텝이 나타나야 다음 단계로 갑니다. diff --git a/.maestro/flows/offline-timers-reconnect.yaml b/.maestro/flows/offline-timers-reconnect.yaml new file mode 100644 index 00000000..5694d06e --- /dev/null +++ b/.maestro/flows/offline-timers-reconnect.yaml @@ -0,0 +1,9 @@ +appId: com.project200.undabang +name: 실제 서버 업로드 완료 뒤에도 미저장 폼과 화면 값 유지 +--- +# 호스트의 독립 GET이 create/75초 update/delete/중복 부재를 확인한 뒤 실행합니다. +# 이 단계도 앱을 재실행하지 않아 서버 결과가 작성 중 폼을 대체하는 오류를 잡습니다. +- runFlow: ../offline/assert-timer-draft.yaml +- back +- back +- runFlow: ../offline/assert-timer-list.yaml diff --git a/.maestro/flows/offline-timers-relaunch.yaml b/.maestro/flows/offline-timers-relaunch.yaml new file mode 100644 index 00000000..9dcba143 --- /dev/null +++ b/.maestro/flows/offline-timers-relaunch.yaml @@ -0,0 +1,19 @@ +appId: com.project200.undabang +name: 오프라인 재실행 후 생성 수정 삭제 영속성과 작성 중 폼 +--- +- stopApp +- runFlow: ../offline/open-timer-list.yaml +- runFlow: ../offline/assert-timer-list.yaml +- runFlow: + file: ../offline/edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_CREATE_TITLE}" + EXPECTED_TIMER_TIME: "00:45" +- back +- back +- scrollUntilVisible: + element: + id: custom_timer_add_card + direction: DOWN + timeout: 30000 +- runFlow: ../offline/prepare-timer-draft.yaml diff --git a/.maestro/offline/assert-exercise-record.yaml b/.maestro/offline/assert-exercise-record.yaml new file mode 100644 index 00000000..9078ab72 --- /dev/null +++ b/.maestro/offline/assert-exercise-record.yaml @@ -0,0 +1,33 @@ +appId: com.project200.undabang +--- +- runFlow: ../subflows/select-exercise-date.yaml +- scrollUntilVisible: + element: + id: exercise_complete_iv + text: "${'운동 기록이 있는 날짜: ' + EXERCISE_START_DATE.split('.').join('-')}" + direction: UP + timeout: 30000 +- assertVisible: + id: exercise_complete_iv + text: "${'운동 기록이 있는 날짜: ' + EXERCISE_START_DATE.split('.').join('-')}" +- scrollUntilVisible: + element: + id: exercise_title_tv + text: "${EXERCISE_TITLE}" + direction: DOWN + timeout: 30000 +- assertVisible: + id: exercise_list_cl + containsDescendants: + - id: exercise_title_tv + text: "${EXERCISE_TITLE}" + - id: exercise_time_tv + text: "${EXERCISE_START_HOUR + ':00 ~ ' + EXERCISE_END_HOUR + ':00'}" +- tapOn: + id: exercise_title_tv + text: "${EXERCISE_TITLE}" +- runFlow: + file: ../subflows/assert-exercise-detail.yaml + env: + RECORD_TITLE: "${EXERCISE_TITLE}" + START_MINUTE: "00" diff --git a/.maestro/offline/assert-timer-draft.yaml b/.maestro/offline/assert-timer-draft.yaml new file mode 100644 index 00000000..0540cb82 --- /dev/null +++ b/.maestro/offline/assert-timer-draft.yaml @@ -0,0 +1,13 @@ +appId: com.project200.undabang +--- +- assertVisible: + id: custom_timer_form +- assertVisible: + id: custom_timer_form_title_field + text: "${TIMER_DRAFT_TITLE}" +- assertVisible: + id: custom_timer_form_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_form_step_time_0 + text: "02:00" diff --git a/.maestro/offline/assert-timer-list.yaml b/.maestro/offline/assert-timer-list.yaml new file mode 100644 index 00000000..3b71fe9c --- /dev/null +++ b/.maestro/offline/assert-timer-list.yaml @@ -0,0 +1,47 @@ +appId: com.project200.undabang +--- +- assertVisible: + id: custom_timer_list +- scrollUntilVisible: + element: + text: 심플 타이머 + direction: UP + timeout: 30000 +# 한 화면의 부재가 아니라 목록 끝의 추가 카드까지 겹치는 영역을 관찰합니다. +- repeat: + times: 200 + while: + notVisible: + id: custom_timer_add_card + commands: + - assertNotVisible: "${TIMER_DELETE_TITLE + '|' + TIMER_ORIGINAL_TITLE + '|' + TIMER_DRAFT_TITLE}" + - swipe: + start: "50%, 60%" + end: "50%, 40%" + duration: 2000 +- assertVisible: + id: custom_timer_add_card +- assertNotVisible: "${TIMER_DELETE_TITLE + '|' + TIMER_ORIGINAL_TITLE + '|' + TIMER_DRAFT_TITLE}" +- scrollUntilVisible: + element: + text: "${TIMER_CREATE_TITLE}" + direction: UP + timeout: 30000 +- assertVisible: "${TIMER_CREATE_TITLE}" +- scrollUntilVisible: + element: + text: 심플 타이머 + direction: UP + timeout: 30000 +- scrollUntilVisible: + element: + text: "${TIMER_UPDATED_TITLE}" + direction: DOWN + timeout: 30000 +- assertVisible: "${TIMER_UPDATED_TITLE}" +# 호출자가 모든 행을 UP 방향으로 찾을 수 있도록 끝으로 돌려놓습니다. +- scrollUntilVisible: + element: + id: custom_timer_add_card + direction: DOWN + timeout: 30000 diff --git a/.maestro/offline/create-timer.yaml b/.maestro/offline/create-timer.yaml new file mode 100644 index 00000000..343980a4 --- /dev/null +++ b/.maestro/offline/create-timer.yaml @@ -0,0 +1,48 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + id: custom_timer_add_card + direction: DOWN + timeout: 30000 +- tapOn: + id: custom_timer_add_card +- extendedWaitUntil: + visible: + id: custom_timer_form_title_field + timeout: 30000 +- tapOn: + id: custom_timer_form_title_field +- inputText: "${NEW_TIMER_TITLE}" +- hideKeyboard +- tapOn: + id: custom_timer_form_new_step_name_field +- inputText: offline-step +- hideKeyboard +- runFlow: + file: set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_form_new_step_time + TIMER_MINUTES: "00" + TIMER_SECONDS: "45" +- tapOn: + id: custom_timer_form_add_step_btn +- assertVisible: + id: custom_timer_form_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_form_step_time_0 + text: "00:45" +- tapOn: + id: custom_timer_form_submit_btn +- extendedWaitUntil: + visible: + id: custom_timer_detail + timeout: 30000 +- assertVisible: "${NEW_TIMER_TITLE}" +- assertVisible: offline-step +- back +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 diff --git a/.maestro/offline/edit-timer.yaml b/.maestro/offline/edit-timer.yaml new file mode 100644 index 00000000..06f70b62 --- /dev/null +++ b/.maestro/offline/edit-timer.yaml @@ -0,0 +1,26 @@ +appId: com.project200.undabang +--- +- scrollUntilVisible: + element: + text: "${EDIT_TIMER_TITLE}" + direction: UP + timeout: 30000 +- tapOn: "${EDIT_TIMER_TITLE}" +- extendedWaitUntil: + visible: + id: custom_timer_menu_btn + timeout: 30000 +- tapOn: + id: custom_timer_menu_btn +- tapOn: 수정하기 +- extendedWaitUntil: + visible: + id: custom_timer_form_title_field + text: "${EDIT_TIMER_TITLE}" + timeout: 30000 +- assertVisible: + id: custom_timer_form_step_name_0 + text: offline-step +- assertVisible: + id: custom_timer_form_step_time_0 + text: "${EXPECTED_TIMER_TIME}" diff --git a/.maestro/offline/open-timer-list.yaml b/.maestro/offline/open-timer-list.yaml new file mode 100644 index 00000000..52fb47eb --- /dev/null +++ b/.maestro/offline/open-timer-list.yaml @@ -0,0 +1,16 @@ +appId: com.project200.undabang +--- +- runFlow: ../subflows/launch-logged-in.yaml +- tapOn: + id: exercise_nav_graph +- scrollUntilVisible: + element: + id: timer_btn + direction: UP + timeout: 30000 +- tapOn: + id: timer_btn +- extendedWaitUntil: + visible: + id: custom_timer_list + timeout: 30000 diff --git a/.maestro/offline/prepare-timer-draft.yaml b/.maestro/offline/prepare-timer-draft.yaml new file mode 100644 index 00000000..e9037b68 --- /dev/null +++ b/.maestro/offline/prepare-timer-draft.yaml @@ -0,0 +1,20 @@ +appId: com.project200.undabang +--- +- runFlow: + file: edit-timer.yaml + env: + EDIT_TIMER_TITLE: "${TIMER_UPDATED_TITLE}" + EXPECTED_TIMER_TIME: "01:15" +- tapOn: + id: custom_timer_form_title_field +- eraseText: 255 +- inputText: "${TIMER_DRAFT_TITLE}" +- hideKeyboard +- runFlow: + file: set-timer-time.yaml + env: + TIMER_TIME_FIELD: custom_timer_form_step_time_0 + TIMER_MINUTES: "02" + TIMER_SECONDS: "00" +- runFlow: assert-timer-draft.yaml +# 완료는 누르지 않습니다. 연결 복귀와 실제 업로드 뒤에도 이 입력이 유지되어야 합니다. diff --git a/.maestro/offline/set-timer-time.yaml b/.maestro/offline/set-timer-time.yaml new file mode 100644 index 00000000..4fac67b4 --- /dev/null +++ b/.maestro/offline/set-timer-time.yaml @@ -0,0 +1,21 @@ +appId: com.project200.undabang +--- +- tapOn: + id: "${TIMER_TIME_FIELD}" +- extendedWaitUntil: + visible: + id: minute_et + timeout: 30000 +- tapOn: + id: minute_et +- eraseText: 2 +- inputText: "${TIMER_MINUTES}" +- tapOn: + id: second_et +- eraseText: 2 +- inputText: "${TIMER_SECONDS}" +- tapOn: + id: confirm_btn +- assertVisible: + id: "${TIMER_TIME_FIELD}" + text: "${TIMER_MINUTES + ':' + TIMER_SECONDS}" diff --git a/.maestro/scripts/features/offline.py b/.maestro/scripts/features/offline.py new file mode 100644 index 00000000..c4cbf3c7 --- /dev/null +++ b/.maestro/scripts/features/offline.py @@ -0,0 +1,227 @@ +"""Run real offline UI phases with independent server observation and safe recovery.""" + +from pathlib import Path +import re +import signal +import time + + +SCENARIOS = ( + ("offline-exercise-create", "offline-exercise-cached"), + ("offline-exercise-missing",), + ("offline-timers-online", "offline-timers-mutate", "offline-timers-relaunch", + "offline-timers-after-reconnect", "offline-timers-reconnect"), +) +FLOWS = tuple(flow for scenario in SCENARIOS for flow in scenario) +HELPERS = ( + "assert-exercise-record", "open-timer-list", "create-timer", "edit-timer", + "set-timer-time", "assert-timer-list", "prepare-timer-draft", "assert-timer-draft", +) + + +def validate_paths(core): + base = core.ROOT / ".maestro" + if base.is_symlink() or not base.is_dir(): + raise core.RunnerFailure("invalid-offline-flow-directory") + for folder, names in (("flows", FLOWS), ("offline", HELPERS)): + directory = base / folder + if directory.is_symlink() or not directory.is_dir(): + raise core.RunnerFailure("invalid-offline-flow-directory") + for name in names: + path = directory / (name + ".yaml") + if path.is_symlink() or not path.is_file(): + raise core.RunnerFailure("selected-offline-flow-or-helper-missing") + + +class NetworkState: + """Restore the original radios, not an assumed Wi-Fi configuration.""" + + KEYS = ("wifi_on", "mobile_data", "airplane_mode_on") + + def __init__(self, core, device): + self.core = core + self.device = device + self.original = self.settings() + self.original_connected = self.connected() + if not self.original_connected: + raise core.RunnerFailure("offline-suite-requires-initial-online-network") + + def settings(self): + values = {} + for key in self.KEYS: + value = self.device.require("shell", "settings", "get", "global", key).strip() + if value not in (b"0", b"1"): + raise self.core.RunnerFailure("unsupported-device-network-settings") + values[key] = value + return values + + def connected(self): + output = self.device.require("shell", "dumpsys", "connectivity").decode("utf-8", "replace") + # Read the current default, never a historical VALIDATED entry. + values = re.findall(r"^\s*Active default network:\s*(none|[0-9]+)\s*$", output, re.MULTILINE) + if len(values) != 1: + raise self.core.RunnerFailure("unsupported-device-connectivity-report") + return values[0] != "none" + + def wait(self, expected, connected): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if self.settings() == expected and self.connected() == connected: + return + time.sleep(0.5) + raise self.core.RunnerFailure("device-network-transition-not-observed") + + def disconnect(self): + self.device.require("shell", "svc", "wifi", "disable") + self.device.require("shell", "svc", "data", "disable") + self.wait({**self.original, "wifi_on": b"0", "mobile_data": b"0"}, False) + + def restore(self): + failed = False + for service, key in (("wifi", "wifi_on"), ("data", "mobile_data")): + try: + self.device.require("shell", "svc", service, "enable" if self.original[key] == b"1" else "disable") + except Exception: + failed = True + if failed: + raise self.core.RunnerFailure("device-network-restore-failed") + self.wait(self.original, self.original_connected) + + +def offline_variables(device): + variables = device.exercise_variables() + run_id = variables["EXERCISE_TITLE"].removeprefix("maestro-e2e-") + prefix = "maestro-offline-" + run_id + return {**variables, "E2E_RUN_ID": run_id, + "TIMER_CREATE_TITLE": prefix + "-create", "TIMER_ORIGINAL_TITLE": prefix + "-update", + "TIMER_UPDATED_TITLE": prefix + "-updated", "TIMER_DELETE_TITLE": prefix + "-delete", + "TIMER_DRAFT_TITLE": prefix + "-draft"} + + +def run_suite(args, core): + errors = {flow: [] for flow in FLOWS} + durations = {flow: 0.0 for flow in FLOWS} + completed = set() + try: + validate_paths(core) + credentials = core.read_credentials() + redactor = core.Redactor(credentials) + device = core.Device(args.device) + if args.configure_emulator: + device.configure_emulator() + device.verify_locale() + if args.apk: + device.install(Path(args.apk).resolve()) + device.verify_setup() + except core.RunInterrupted: + return [(flow, 0, ["run-interrupted"]) for flow in FLOWS] + except core.RunnerFailure as failure: + return [(flow, 0, [str(failure)]) for flow in FLOWS] + except Exception: + return [(flow, 0, ["offline-driver-setup-failed"]) for flow in FLOWS] + + for scenario in SCENARIOS: + network = None + prepared = False + safe_to_continue = True + owner = scenario[0] + try: + network = NetworkState(core, device) + variables = core.safe_variables(offline_variables(device)) + run_id = variables["E2E_RUN_ID"] + parameters = { + "startedAt": variables["EXERCISE_START_DATE"].replace(".", "-") + "T" + variables["EXERCISE_START_HOUR"] + ":00:00", + "endedAt": variables["EXERCISE_END_DATE"].replace(".", "-") + "T" + variables["EXERCISE_END_HOUR"] + ":00:00", + } + metadata = {"feature": "offline", "flow": owner, "run_id": run_id, "parameters": parameters} + output = args.output / "offline" / owner + output.mkdir(parents=True, exist_ok=True) + (output / "ownership.log").write_text("E2E_RUN_ID=" + run_id + "\n", encoding="utf-8") + safe_to_continue = False + device.clear() + safe_to_continue = True + prepared = True + device.bootstrap("prepare", credentials, **metadata) + + def phase(flow): + nonlocal owner + owner = flow + started = time.monotonic() + try: + core.run_maestro(device, flow, output, redactor, args.flow_timeout, variables=variables) + completed.add(flow) + finally: + durations[flow] += time.monotonic() - started + + def observe(stage): + device.bootstrap("offline-observe", { + "memberId": credentials["memberId"], "runId": run_id, "flow": scenario[0], + **parameters, "stage": stage, + }) + + if scenario == SCENARIOS[0]: + phase("offline-exercise-create") + observe("exercise-online") + network.disconnect() + phase("offline-exercise-cached") + elif scenario == SCENARIOS[1]: + network.disconnect() + phase("offline-exercise-missing") + else: + phase("offline-timers-online") + observe("timers-online") + network.disconnect() + phase("offline-timers-mutate") + phase("offline-timers-relaunch") + network.restore() + phase("offline-timers-after-reconnect") + observe("timers-synced") + phase("offline-timers-reconnect") + except core.RunInterrupted: + errors[owner].append("run-interrupted") + safe_to_continue = False + except core.RunnerFailure as failure: + errors[owner].append(str(failure)) + except Exception: + errors[owner].append("offline-driver-operation-failed") + finally: + handlers = {sig: signal.signal(sig, signal.SIG_IGN) for sig in (signal.SIGINT, signal.SIGTERM)} + try: + restored = True + if network is not None: + try: + network.restore() + except Exception: + restored = False + safe_to_continue = False + for flow in scenario: + errors[flow].append("device-network-restore-failed") + cleanup_failed = False + if prepared and restored: + try: + device.bootstrap("cleanup", credentials, **metadata) + except Exception: + cleanup_failed = True + safe_to_continue = False + for flow in scenario: + errors[flow].append("fixture-cleanup-failed") + if prepared and (not restored or cleanup_failed): + for flow in scenario: + errors[flow].append("private-state-preserved-for-recovery") + elif prepared: + try: + device.clear() + except Exception: + safe_to_continue = False + for flow in scenario: + errors[flow].append("final-app-data-clear-failed") + finally: + for sig, handler in handlers.items(): + signal.signal(sig, handler) + if not safe_to_continue: + break + + for flow in FLOWS: + if flow not in completed: + errors[flow].append("offline-phase-not-executed") + return [(flow, durations[flow], errors[flow]) for flow in FLOWS] diff --git a/.maestro/scripts/features/test_offline.py b/.maestro/scripts/features/test_offline.py new file mode 100644 index 00000000..92383ad4 --- /dev/null +++ b/.maestro/scripts/features/test_offline.py @@ -0,0 +1,321 @@ +"""Exercise offline driver boundaries without devices, accounts, or network calls.""" + +import importlib.util +from pathlib import Path +import signal +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + + +SPEC = importlib.util.spec_from_file_location("offline_test_core", Path(__file__).parents[1] / "run-tests.py") +core = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(core) +SPEC = importlib.util.spec_from_file_location("offline_test_driver", Path(__file__).with_name("offline.py")) +offline = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(offline) +CREDENTIALS = {"authState": {"accessToken": "synthetic-private-token"}, "memberId": "dedicated-member", + "fixtureStartDate": "2026-01-01"} + + +class Clock: + def __init__(self): + self.now = 0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +class RadioDevice: + def __init__(self): + self.values = {"wifi_on": b"0", "mobile_data": b"1", "airplane_mode_on": b"0"} + self.calls = [] + self.external_network = False + self.failed_radio = None + self.report = None + + def require(self, *args): + self.calls.append(args) + if args[:4] == ("shell", "settings", "get", "global"): + return self.values[args[4]] + b"\n" + if args == ("shell", "dumpsys", "connectivity"): + if self.report is not None: + return self.report + connected = self.external_network or any(self.values[key] == b"1" for key in ("wifi_on", "mobile_data")) + return b"Active default network: " + (b"100" if connected else b"none") + b"\nHistory: VALIDATED network 99\n" + if args[:2] == ("shell", "svc"): + if args[2] == self.failed_radio: + raise core.RunnerFailure("adb-operation-failed") + key = {"wifi": "wifi_on", "data": "mobile_data"}[args[2]] + self.values[key] = b"1" if args[3] == "enable" else b"0" + return b"" + raise AssertionError("Unexpected radio command") + + +class OfflineNetworkTests(unittest.TestCase): + def setUp(self): + self.device = RadioDevice() + self.clock = Clock() + patch = mock.patch.object(offline, "time", self.clock) + patch.start() + self.addCleanup(patch.stop) + + def test_disconnect_checks_current_default_and_restores_original_radios(self): + original = dict(self.device.values) + network = offline.NetworkState(core, self.device) + network.disconnect() + self.assertFalse(network.connected()) + network.restore() + self.assertEqual(self.device.values, original) + self.assertTrue(network.connected()) + self.assertNotIn(("shell", "svc", "wifi", "enable"), self.device.calls) + + def test_ethernet_or_other_default_prevents_false_offline_success(self): + network = offline.NetworkState(core, self.device) + self.device.external_network = True + with self.assertRaisesRegex(core.RunnerFailure, "^device-network-transition-not-observed$"): + network.disconnect() + self.assertEqual(self.clock.now, 30) + network.restore() + self.assertEqual(self.device.values["mobile_data"], b"1") + + def test_restore_attempts_both_radios_after_partial_failure(self): + network = offline.NetworkState(core, self.device) + network.disconnect() + self.device.failed_radio = "wifi" + with self.assertRaisesRegex(core.RunnerFailure, "^device-network-restore-failed$"): + network.restore() + self.assertEqual(self.device.values["mobile_data"], b"1") + self.assertIn(("shell", "svc", "data", "enable"), self.device.calls) + + def test_unknown_settings_and_reports_fail_before_radio_changes(self): + for report in (b"", b"History: Active default network: 100\n", + b"Active default network: none\nActive default network: 100\n"): + with self.subTest(report=report): + self.device.report = report + with self.assertRaisesRegex(core.RunnerFailure, "^unsupported-device-connectivity-report$"): + offline.NetworkState(core, self.device) + self.device.values["mobile_data"] = b"null" + with self.assertRaisesRegex(core.RunnerFailure, "^unsupported-device-network-settings$"): + offline.NetworkState(core, self.device) + self.assertFalse(any(args[:2] == ("shell", "svc") for args in self.device.calls)) + + def test_initially_offline_device_is_not_silently_reconfigured(self): + self.device.values["mobile_data"] = b"0" + with self.assertRaisesRegex(core.RunnerFailure, "^offline-suite-requires-initial-online-network$"): + offline.NetworkState(core, self.device) + self.assertFalse(any(args[:2] == ("shell", "svc") for args in self.device.calls)) + + +class OfflineDriverTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="maestro-offline-driver-") + self.addCleanup(temporary.cleanup) + self.output = Path(temporary.name) + self.events = [] + self.failed_phase = None + self.interrupted = False + self.fail_restore = False + self.fail_cleanup = False + self.fail_prepare = False + self.fail_upload = False + self.device = mock.Mock() + self.device.clear.side_effect = lambda: self.events.append("clear") + self.device.bootstrap.side_effect = self.bootstrap + self.device.exercise_variables.side_effect = self.variables + self.index = 0 + self.network = mock.Mock() + self.network.disconnect.side_effect = lambda: self.events.append("disconnect") + self.network.restore.side_effect = self.restore + for patch in ( + mock.patch.object(core, "Device", return_value=self.device), + mock.patch.object(core, "read_credentials", return_value=CREDENTIALS), + mock.patch.object(core, "run_maestro", side_effect=self.phase), + mock.patch.object(offline, "NetworkState", return_value=self.network), + mock.patch.object(offline, "signal", SimpleNamespace( + SIGINT=signal.SIGINT, SIGTERM=signal.SIGTERM, SIG_IGN=signal.SIG_IGN, + signal=mock.Mock(return_value=signal.SIG_DFL), + )), + ): + patch.start() + self.addCleanup(patch.stop) + + def variables(self): + self.index += 1 + title = "maestro-e2e-" + ("%032x" % self.index) + return {"EXERCISE_TITLE": title, "EDITED_EXERCISE_TITLE": title + "-edited", + "EXERCISE_START_DATE": "2026.10.15", "EXERCISE_END_DATE": "2026.10.15", + "EXERCISE_START_HOUR": "09", "EXERCISE_END_HOUR": "10"} + + def bootstrap(self, operation, request, **metadata): + stage = request.get("stage") + self.events.append(operation + (":" + stage if stage else "")) + if operation == "offline-observe": + self.assertNotIn("authState", request) + self.assertEqual(metadata, {}) + self.assertEqual(request["memberId"], CREDENTIALS["memberId"]) + if stage == "timers-synced" and self.fail_upload: + raise core.RunnerFailure("offline-suite-requires-real-server-upload") + else: + self.assertEqual(request, CREDENTIALS) + self.assertEqual(metadata["feature"], "offline") + self.assertIn(metadata["flow"], tuple(scenario[0] for scenario in offline.SCENARIOS)) + self.assertRegex(metadata["run_id"], r"\A[a-f0-9]{32}\Z") + self.assertEqual(set(metadata["parameters"]), {"startedAt", "endedAt"}) + if operation == "prepare" and self.fail_prepare: + raise core.RunnerFailure("offline-suite-requires-enabled-cache-flag") + if operation == "cleanup" and self.fail_cleanup: + raise core.RunnerFailure("bootstrap-cleanup-failed") + return {} + + def restore(self): + self.events.append("restore") + if self.fail_restore: + raise core.RunnerFailure("device-network-restore-failed") + + def phase(self, _device, name, _output, _redactor, _timeout, **kwargs): + self.events.append(name) + self.assertNotIn("authState", kwargs["variables"]) + self.assertNotIn("memberId", kwargs["variables"]) + if name == self.failed_phase: + failure = core.RunInterrupted if self.interrupted else core.RunnerFailure + raise failure("run-interrupted" if self.interrupted else "maestro-flow-failed") + + def run_driver(self): + args = SimpleNamespace(output=self.output, device="synthetic", configure_emulator=False, + apk=None, flow_timeout=42) + result = offline.run_suite(args, core) + self.assertEqual([flow for flow, _, _ in result], list(offline.FLOWS)) + self.assertEqual(core.validate_driver_results(offline.FLOWS, result), result) + self.assertNotIn("synthetic-private-token", str(result)) + return {flow: errors for flow, _, errors in result} + + def test_every_declared_phase_executes_with_cache_miss_isolated_and_cleanup_ordered(self): + result = self.run_driver() + self.assertTrue(all(not errors for errors in result.values())) + self.assertEqual(self.events, [ + "clear", "prepare", "offline-exercise-create", "offline-observe:exercise-online", + "disconnect", "offline-exercise-cached", "restore", "cleanup", "clear", + "clear", "prepare", "disconnect", "offline-exercise-missing", "restore", "cleanup", "clear", + "clear", "prepare", "offline-timers-online", "offline-observe:timers-online", "disconnect", + "offline-timers-mutate", "offline-timers-relaunch", "restore", "offline-timers-after-reconnect", + "offline-observe:timers-synced", "offline-timers-reconnect", "restore", "cleanup", "clear", + ]) + calls = self.device.bootstrap.call_args_list + for prepare in (call for call in calls if call.args[0] == "prepare"): + cleanup = next(call for call in calls if call.args[0] == "cleanup" and call.kwargs == prepare.kwargs) + self.assertEqual(prepare.args[1], cleanup.args[1]) + self.assertEqual(len({call.kwargs["run_id"] for call in calls if call.args[0] == "prepare"}), 3) + + def test_interruption_restores_before_cleanup_and_reports_unexecuted_phases(self): + self.failed_phase = "offline-exercise-cached" + self.interrupted = True + result = self.run_driver() + self.assertIn("run-interrupted", result[self.failed_phase]) + self.assertEqual(self.events[-3:], ["restore", "cleanup", "clear"]) + self.assertNotIn("offline-exercise-missing", self.events) + self.assertIn("offline-phase-not-executed", result["offline-timers-online"]) + + def test_missing_upload_fails_the_observation_owner_and_does_not_claim_final_form_execution(self): + self.fail_upload = True + result = self.run_driver() + self.assertIn("offline-suite-requires-real-server-upload", result["offline-timers-after-reconnect"]) + self.assertIn("offline-phase-not-executed", result["offline-timers-reconnect"]) + self.assertNotIn("offline-timers-reconnect", self.events) + self.assertEqual(self.events[-3:], ["restore", "cleanup", "clear"]) + + def test_prepare_failure_still_cleans_the_same_ownership_without_ui(self): + self.fail_prepare = True + result = self.run_driver() + self.assertIn("offline-suite-requires-enabled-cache-flag", result["offline-exercise-create"]) + self.assertEqual(self.events, ["clear", "prepare", "restore", "cleanup", "clear"] * 3) + + def test_failed_restoration_preserves_private_state_and_stops_other_scenarios(self): + self.failed_phase = "offline-exercise-cached" + self.fail_restore = True + result = self.run_driver() + self.assertEqual(self.events[-1], "restore") + self.assertNotIn("cleanup", self.events) + self.assertEqual(self.device.clear.call_count, 1) + self.assertIn("private-state-preserved-for-recovery", result["offline-exercise-create"]) + self.assertIn("device-network-restore-failed", result[self.failed_phase]) + self.assertNotIn("offline-exercise-missing", self.events) + + def test_failed_fixture_cleanup_does_not_clear_private_recovery_or_run_later_tests(self): + self.fail_cleanup = True + result = self.run_driver() + self.assertEqual(self.events[-2:], ["restore", "cleanup"]) + self.assertEqual(self.device.clear.call_count, 1) + self.assertIn("fixture-cleanup-failed", result["offline-exercise-create"]) + self.assertIn("private-state-preserved-for-recovery", result["offline-exercise-cached"]) + self.assertNotIn("offline-exercise-missing", self.events) + + def test_pending_recovery_clear_refusal_never_prepares_or_runs_a_later_scenario(self): + self.device.clear.side_effect = core.RunnerFailure("pending-feature-restoration-refuses-app-data-clear") + result = self.run_driver() + self.assertEqual(self.device.clear.call_count, 1) + self.device.bootstrap.assert_not_called() + self.assertIn("pending-feature-restoration-refuses-app-data-clear", result["offline-exercise-create"]) + self.assertTrue(all(errors for errors in result.values())) + + def test_ui_failure_cleans_and_cannot_be_hidden_by_later_success(self): + self.failed_phase = "offline-exercise-cached" + result = self.run_driver() + self.assertIn("maestro-flow-failed", result[self.failed_phase]) + self.assertEqual(result["offline-timers-reconnect"], []) + self.assertEqual(self.events[6:9], ["restore", "cleanup", "clear"]) + + def test_partial_disconnect_failure_still_restores_before_cleanup(self): + def fail_disconnect(): + self.events.append("partial-disconnect") + raise core.RunnerFailure("adb-operation-failed") + + self.network.disconnect.side_effect = fail_disconnect + result = self.run_driver() + self.assertIn("adb-operation-failed", result["offline-exercise-create"]) + index = self.events.index("partial-disconnect") + self.assertEqual(self.events[index + 1:index + 4], ["restore", "cleanup", "clear"]) + self.assertNotIn("offline-exercise-cached", self.events) + + def test_final_clear_failure_stops_later_scenarios_and_fails_completed_phases(self): + calls = 0 + + def clear(): + nonlocal calls + calls += 1 + self.events.append("clear") + if calls == 2: + raise core.RunnerFailure("app-data-clear-failed") + + self.device.clear.side_effect = clear + result = self.run_driver() + self.assertIn("final-app-data-clear-failed", result["offline-exercise-create"]) + self.assertIn("final-app-data-clear-failed", result["offline-exercise-cached"]) + self.assertNotIn("offline-exercise-missing", self.events) + + def test_missing_or_symlinked_helper_fails_before_device_setup(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for folder, names in (("flows", offline.FLOWS), ("offline", offline.HELPERS)): + parent = root / ".maestro" / folder + parent.mkdir(parents=True) + for name in names: + (parent / (name + ".yaml")).write_text("synthetic test fixture") + helper = root / ".maestro" / "offline" / "create-timer.yaml" + helper.unlink() + with mock.patch.object(core, "ROOT", root): + result = self.run_driver() + self.assertTrue(all("selected-offline-flow-or-helper-missing" in errors for errors in result.values())) + helper.symlink_to(root / ".maestro" / "flows" / (offline.FLOWS[0] + ".yaml")) + result = self.run_driver() + self.assertTrue(all("selected-offline-flow-or-helper-missing" in errors for errors in result.values())) + self.device.verify_setup.assert_not_called() + self.device.bootstrap.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 67915df4..854e1a63 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -758,18 +758,22 @@ def remove_private(self, include_session=False): def bootstrap(self, operation, credentials=None, timeout=300, feature=None, flow=None, run_id=None, parameters=None): - if operation not in ("prepare", "cleanup", "export"): + if operation not in ("prepare", "cleanup", "export", "offline-observe"): raise RunnerFailure("invalid-bootstrap-operation") metadata = feature_metadata(operation, feature, flow, run_id, parameters) request = {"operation": operation, **(credentials or {}), **metadata} - self.require("shell", "am", "force-stop", APP_ID) + # 관찰은 실행 중 Worker와 미저장 폼을 중단하거나 세션/Room을 초기화하지 않습니다. + if operation != "offline-observe": + self.require("shell", "am", "force-stop", APP_ID) self.remove_private(include_session=operation == "export") payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8") # shell -T는 PTY 없이 stdin과 원격 종료 상태를 전달합니다. argv에는 고정 명령만 넣습니다. script = "umask 077; mkdir -p files && cat > " + REQUEST self.require("shell", "-T", "run-as", APP_ID, "sh", "-c", shlex.quote(script), payload=payload) self.require("shell", "am", "start", "-W", "-n", COMPONENT) - expected = {"prepare": "ready", "cleanup": "cleaned", "export": "exported"}[operation] + expected = { + "prepare": "ready", "cleanup": "cleaned", "export": "exported", "offline-observe": "observed", + }[operation] # 결과 파일이 아직 없을 때만 기다리고, 실제 읽기 실패의 종료 상태는 보존합니다. probe = "if [ ! -e " + RESULT + " ]; then exit 3; fi; cat " + RESULT deadline = time.monotonic() + timeout @@ -785,14 +789,21 @@ 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 isinstance(result, dict) - and result.get("status") == "failed" and isinstance(result.get("reason"), str)): - if feature == "profile": - failure = PROFILE_PREPARE_FAILURES.get(result["reason"], failure) - elif feature == "matching": - failure = MATCHING_PREPARE_FAILURES.get(result["reason"], failure) + if isinstance(result, dict) and result.get("status") == "failed": + reason = result.get("reason") + if isinstance(reason, str): + if operation == "prepare" and feature == "profile": + failure = PROFILE_PREPARE_FAILURES.get(reason, failure) + elif operation == "prepare" and feature == "matching": + failure = MATCHING_PREPARE_FAILURES.get(reason, failure) + elif feature == "offline" or operation == "offline-observe": + failure = { + "offline_cache_flag_disabled": "offline-suite-requires-enabled-cache-flag", + "offline_upload_not_observed": "offline-suite-requires-real-server-upload", + "offline_exercise_server_mismatch": "offline-exercise-server-values-mismatch", + }.get(reason, failure) raise RunnerFailure(failure) variables = safe_variables(result.get("variables", {})) self.remove_private() @@ -800,6 +811,8 @@ def bootstrap(self, operation, credentials=None, timeout=300, feature=None, flow raise RunnerFailure("bootstrap-" + operation + "-timeout") + + def secure_export_path(value): path = Path(value).expanduser() if not path.is_absolute(): diff --git a/.maestro/scripts/test_feature_suites.py b/.maestro/scripts/test_feature_suites.py index d14d6990..ca93307b 100755 --- a/.maestro/scripts/test_feature_suites.py +++ b/.maestro/scripts/test_feature_suites.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Check suite validation and restoration failure boundaries without devices.""" +import contextlib import importlib.util +import io import json from pathlib import Path import sys @@ -256,5 +258,93 @@ def test_redaction_includes_short_peer_room_identifiers(self): self.assertNotIn("42", cleaned) +class InstalledSuiteTests(unittest.TestCase): + def test_all_selects_every_installed_case_and_keeps_orchestrated_helpers_out(self): + suites = runner.select_suites("all") + expected = [ + "smoke-tabs", "exercise-form-entry", "exercise-crud", + "chatting-send", "chatting-receive", "chatting-list-refresh", "chatting-history", + "feed-post-crud", "feed-like", "feed-comment", "feed-photo", "feed-form-validation", + "matching-place-crud", "matching-place-validation", "matching-member-to-chat", + "offline-exercise-create", "offline-exercise-cached", "offline-exercise-missing", + "offline-timers-online", "offline-timers-mutate", "offline-timers-relaunch", + "offline-timers-after-reconnect", "offline-timers-reconnect", + "profile-edit", "profile-edit-validation", "profile-preferred-exercise", "profile-notification", + "timer-simple-preset-crud", "timer-simple-run", "timer-custom-crud", "timer-custom-run", + "timer-form-validation", "login-screen", + ] + self.assertEqual(runner.selected_flows(suites), expected) + self.assertEqual(runner.required_api(suites), 34) + installed = {path.stem for path in (runner.ROOT / ".maestro" / "flows").glob("*.yaml")} + self.assertEqual(installed - set(expected), {"chatting-open", "chatting-ready", "chatting-away"}) + for name in ("chatting", "offline"): + suite = next(suite for suite in suites if suite["name"] == name) + self.assertEqual(suite["driver"], name) + self.assertTrue(callable(runner.load_feature_module(suite).run_suite)) + self.assertEqual( + runner.selected_flows(runner.select_suites("ci")), + [flow for flow in expected if flow not in runner.FLOWS["regression"]], + ) + self.assertEqual( + runner.selected_flows(runner.select_suites("offline")), + next(suite["flows"] for suite in suites if suite["name"] == "offline"), + ) + + def run_all(self, omit_offline_result=False): + suites = runner.select_suites("all") + ordinary = [] + drivers = {} + for suite in suites: + if suite["driver"]: + flows = suite["flows"][:-1] if omit_offline_result and suite["name"] == "offline" else suite["flows"] + drivers[suite["name"]] = SimpleNamespace(run_suite=mock.Mock( + return_value=[(flow, 1.0, []) for flow in flows], + )) + + def run_ordinary(_args, _device, _suite, flow, *_rest): + ordinary.append(flow) + return (flow, 1.0, []), True + + with tempfile.TemporaryDirectory(prefix="maestro-all-dispatch-") as directory: + root = Path(directory) + with ( + mock.patch.object(runner, "ROOT", root), + mock.patch.object(runner, "select_suites", return_value=suites), + mock.patch.object(runner, "read_required_environment", return_value={}), + mock.patch.object(runner, "read_credentials", return_value={"fixtureStartDate": "2026-01-01"}), + mock.patch.object(runner, "Device"), + mock.patch.object(runner, "load_feature_module", side_effect=lambda suite: drivers.get(suite["name"])), + mock.patch.object(runner, "run_ordinary_flow", side_effect=run_ordinary), + contextlib.redirect_stdout(io.StringIO()), + ): + code = runner.run_suite(SimpleNamespace(suite="all", device="primary", apk=None, + configure_emulator=False, flow_timeout=42)) + reports = list((root / "build" / "maestro").glob("*/report.xml")) + self.assertEqual(len(reports), 1) + cases = {case.get("name"): case for case in runner.ET.parse(reports[0]).getroot().findall("testcase")} + return code, cases, ordinary, drivers + + def test_all_dispatches_chat_and_offline_to_drivers_not_single_device_iteration(self): + code, cases, ordinary, drivers = self.run_all() + self.assertEqual(code, 0) + self.assertEqual(len(cases), 33) + self.assertIn("exercise-crud", ordinary) + for name, module in drivers.items(): + module.run_suite.assert_called_once() + args, core = module.run_suite.call_args.args + self.assertEqual(args.device, "primary") + self.assertIs(core, runner) + self.assertFalse(any(flow.startswith(name + "-") for flow in ordinary)) + + def test_omitted_offline_phase_fails_and_blocks_later_suites_without_skip_green(self): + code, cases, ordinary, _drivers = self.run_all(omit_offline_result=True) + self.assertEqual(code, 1) + self.assertEqual(len(cases), 33) + self.assertIn("driver-flow-not-reported", cases["offline-timers-reconnect"].find("failure").get("message")) + for flow in ("profile-edit", "timer-custom-crud", "login-screen"): + self.assertNotIn(flow, ordinary) + self.assertIn("not-executed", cases[flow].find("failure").get("message")) + + if __name__ == "__main__": unittest.main() diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index da1ba622..ca3e22b9 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -258,6 +258,49 @@ def test_cleanup_waits_for_cleaned_status(self): self.assertFalse((self.transport.root / runner.RESULT).exists()) self.assert_no_credential_arguments() + def test_offline_observation_keeps_running_process_and_sends_no_auth_state(self): + self.transport.result = b'{"status":"observed"}' + request = { + "memberId": CREDENTIALS["memberId"], "runId": "0123456789abcdef0123456789abcdef", + "startedAt": "2026-10-15T09:00:00", "endedAt": "2026-10-15T10:00:00", + "stage": "timers-synced", "flow": "offline-timers-online", + } + self.device.bootstrap("offline-observe", request) + self.assertEqual(json.loads(self.transport.request_bytes), {"operation": "offline-observe", **request}) + self.assertFalse(any("force-stop" in argv for argv, _env in self.transport.calls)) + self.assertNotIn("authState", json.loads(self.transport.request_bytes)) + self.assertEqual(self.transport.request_mode, 0o600) + self.assert_no_credential_arguments() + + def test_offline_prerequisite_failures_use_only_allowlisted_messages(self): + self.transport.pending_sleeps = 0 + for reason, expected in ( + ("offline_cache_flag_disabled", "offline-suite-requires-enabled-cache-flag"), + ("offline_upload_not_observed", "offline-suite-requires-real-server-upload"), + (SYNTHETIC_SECRET, "bootstrap-offline-observe-failed"), + ): + with self.subTest(reason=reason): + self.transport.result = json.dumps({"status": "failed", "reason": reason}).encode() + self.assert_failure(expected, lambda: self.device.bootstrap("offline-observe", {"stage": "timers-synced"})) + + def test_offline_preparation_uses_the_common_feature_contract_and_fixed_flag_failure(self): + self.transport.result = b'{"status":"failed","reason":"offline_cache_flag_disabled"}' + metadata = { + "feature": "offline", "flow": "offline-exercise-create", + "run_id": "0123456789abcdef0123456789abcdef", + "parameters": {"startedAt": "2026-10-15T09:00:00", "endedAt": "2026-10-15T10:00:00"}, + } + self.assert_failure( + "offline-suite-requires-enabled-cache-flag", + lambda: self.device.bootstrap("prepare", CREDENTIALS, **metadata), + ) + request = json.loads(self.transport.request_bytes) + self.assertEqual(request["operation"], "prepare") + self.assertEqual(request["feature"], "offline") + self.assertEqual(request["parameters"], metadata["parameters"]) + self.assertEqual(request["authState"], CREDENTIALS["authState"]) + self.assert_no_credential_arguments() + def test_empty_and_malformed_results_fail_without_polling_again(self): self.transport.pending_sleeps = 0 for payload in (b"", b" \n\t", b'{"status":', b"not-json"): diff --git a/.maestro/suites/offline.json b/.maestro/suites/offline.json new file mode 100644 index 00000000..fdd2f388 --- /dev/null +++ b/.maestro/suites/offline.json @@ -0,0 +1,16 @@ +{ + "flows": [ + "offline-exercise-create", + "offline-exercise-cached", + "offline-exercise-missing", + "offline-timers-online", + "offline-timers-mutate", + "offline-timers-relaunch", + "offline-timers-after-reconnect", + "offline-timers-reconnect" + ], + "apiLevel": 30, + "fixture": true, + "requiredEnvironment": [], + "driver": "offline" +} diff --git a/README.md b/README.md index 0f3a3fcb..648285b8 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ 구현 사례별 소스와 테스트는 위 링크에서 확인할 수 있습니다. - **로직·저장소**: UseCase·ViewModel 단위 테스트, Robolectric 기반 Room 테스트를 두고 있습니다. JaCoCo 집계 대상은 UseCase·ViewModel입니다. -- **사용자 흐름**: Maestro 플로우로 로그인 화면, 하단 탭과 콘텐츠, 운동 기록 작성 화면 진입을 검사하도록 구성했습니다. 기기 준비와 실행 조건은 [Maestro 가이드](.maestro/README.md)에 있습니다. +- **사용자 흐름**: Maestro로 로그인/탭·운동 CRUD·피드·타이머·매칭·두 계정/두 기기 채팅·프로필과 오프라인 회귀를 검사합니다. trusted dev PR/push의 `all`은 설치된 전체 33개 case를 선택합니다. 오프라인은 #584/#588/#589·서버 #574/#575 배포와 활성화된 캐시 플래그가 필요하며 미충족 시 실패합니다. 실행 조건과 정확한 목록은 [Maestro 가이드](.maestro/README.md)에 있습니다. - **CI/CD 구성**: `dev` push·PR은 debug 검증과 Firebase App Distribution 배포, `master` push·PR은 release 검증을 수행하도록 설정되어 있습니다. Google Play 내부 테스트 배포는 `master` push에서만 실행합니다. [dev 워크플로우](.github/workflows/android-dev-ci.yml) · [master 워크플로우](.github/workflows/android-master-ci-cd.yml) @@ -202,7 +202,8 @@ ### 현재 지원 범위 - 타이머의 로컬 저장과 달리, 현재 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)의 목록·상세·횟수 조회는 서버를 호출합니다. Room 저장 계층이 있다는 이유만으로 앱 전체의 오프라인 지원을 의미하지는 않습니다. -- CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. Maestro도 현재 CI에서 실행하지 않습니다. +- CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. 별도 DEV Maestro CI는 `all`로 운동 회귀와 모든 설치 descriptor를 자동 선택합니다. 수동 `offline` 진단도 같은 실제 driver를 사용하며 의존 기능 부재를 skip으로 통과시키지 않습니다. +- `offline`의 재접속 시나리오는 최종 UI·서버 값을 검사합니다. pending 상태에서 성공한 원격 목록을 적용하는 순서의 검증은 실제 #588/#589 uploader 관찰/제어 계약이 없어 **BLOCKED**이며, suite 통과만으로 #617 전체 완료를 뜻하지 않습니다. - `dev` 워크플로우에는 배포를 push로 제한하는 조건이 없어 PR에도 배포 단계가 있습니다. 배포 구성은 공개 출시 여부나 실제 배포 성공을 뜻하지 않습니다. ## 실행 방법 diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtureModule.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtureModule.kt new file mode 100644 index 00000000..e8c11637 --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtureModule.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 MaestroOfflineFixtureModule { + @Binds + @IntoMap + @StringKey("offline") + abstract fun bindOfflineFixture(impl: MaestroOfflineFixtures): MaestroFeatureFixture +} diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt new file mode 100644 index 00000000..828c736d --- /dev/null +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroOfflineFixtures.kt @@ -0,0 +1,202 @@ +package com.project200.undabang.e2e + +import com.project200.data.api.ApiService +import com.project200.data.datasource.FeatureFlagDataSource +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.GetCustomTimerDetailDTO +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.domain.repository.AppUpdateRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import java.time.LocalDate +import java.time.LocalDateTime +import javax.inject.Inject + +internal data class MaestroOfflineRun( + val id: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, +) { + init { + if (!ID_PATTERN.matches(id) || endedAt != startedAt.plusHours(1) || + startedAt.minute != 0 || startedAt.second != 0 || startedAt.nano != 0 + ) { + throw MaestroSetupFailure("invalid_request") + } + } + + val exerciseTitle: String = "maestro-e2e-$id" + val createdTimer: String = "maestro-offline-$id-create" + val originalTimer: String = "maestro-offline-$id-update" + val updatedTimer: String = "maestro-offline-$id-updated" + val deletedTimer: String = "maestro-offline-$id-delete" + val draftTimer: String = "maestro-offline-$id-draft" + val timerNames: Set = setOf(createdTimer, originalTimer, updatedTimer, deletedTimer, draftTimer) + + companion object { + private val ID_PATTERN = Regex("[a-f0-9]{32}") + } +} + +/** 생성·수정은 UI와 실제 동기화만 수행합니다. 이 클래스는 직접 GET과 실행별 정리만 수행합니다 */ +internal class MaestroOfflineFixtures + @Inject + constructor( + private val api: ApiService, + private val featureFlags: FeatureFlagDataSource, + private val appUpdateRepository: AppUpdateRepository, + ) : MaestroFeatureFixture { + override suspend fun prepare(request: MaestroFeatureRequest): Map { + val run = run(request) + validateDate(run) + clean(run) + requireCacheEnabled() + return emptyMap() + } + + override suspend fun cleanup(request: MaestroFeatureRequest) { + val run = run(request) + validateDate(run) + clean(run) + } + + suspend fun requireCacheEnabled() { + // 기본값 false인 플래그를 가정하지 않고 실제 fetch/activate 경로에서 확인합니다. + appUpdateRepository.getUpdateInfo().getOrElse { throw MaestroSetupFailure("offline_flag_fetch_failed") } + if (!featureFlags.isOfflineCacheEnabled()) throw MaestroSetupFailure("offline_cache_flag_disabled") + } + + fun validateDate(run: MaestroOfflineRun) { + if (run.endedAt.toLocalDate() > LocalDate.now()) throw MaestroSetupFailure("fixture_range_invalid") + } + + suspend fun observe( + run: MaestroOfflineRun, + stage: String, + ) { + if (stage == "exercise-online") { + val records = exerciseRecords(run) + if (records.size != 1) throw MaestroSetupFailure("offline_exercise_server_mismatch") + val detail = read(api.getExerciseRecordDetail(records.single().exerciseId)) + if (detail.exerciseTitle != run.exerciseTitle || detail.exerciseStartedAt != run.startedAt || + detail.exerciseEndedAt != run.endedAt + ) { + throw MaestroSetupFailure("offline_exercise_server_mismatch") + } + return + } + val expected = + when (stage) { + "timers-online" -> mapOf(run.originalTimer to 45, run.deletedTimer to 45) + "timers-synced" -> mapOf(run.createdTimer to 45, run.updatedTimer to 75) + else -> throw MaestroSetupFailure("invalid_request") + } + val observed = + withTimeoutOrNull(UPLOAD_TIMEOUT_MS) { + var matches = timersMatch(run, expected) + while (!matches) { + delay(2000) + matches = timersMatch(run, expected) + } + true + } + if (observed != true) throw MaestroSetupFailure("offline_upload_not_observed") + } + + private suspend fun timersMatch( + run: MaestroOfflineRun, + expected: Map, + ): Boolean { + val summaries = timerSummaries() + val owned = summaries.filter { it.customTimerName in run.timerNames } + if (owned.size != expected.size || owned.map { it.customTimerName }.toSet() != expected.keys) return false + for (summary in owned) { + val detail = read(api.getCustomTimer(summary.customTimerId)) + if (!matchesTimer(detail, summary.customTimerId, summary.customTimerName, expected.getValue(summary.customTimerName))) { + return false + } + } + return true + } + + suspend fun clean(run: MaestroOfflineRun) { + // 호출자가 DEV 세션과 전용 member ID를 검증한 뒤에만 실행합니다. + val exercises = exerciseRecords(run) + val timers = timerSummaries().filter { it.customTimerName in run.timerNames } + // 양쪽 목록 GET이 성공한 뒤 UUID와 상세를 재확인한 행만 삭제합니다. + for (record in exercises) { + val detail = read(api.getExerciseRecordDetail(record.exerciseId)) + if (detail.exerciseTitle != run.exerciseTitle) continue + if (detail.exerciseStartedAt.toLocalDate() != run.startedAt.toLocalDate()) { + throw MaestroSetupFailure("fixture_read_failed") + } + if (!api.deleteExerciseRecord(record.exerciseId).succeed) throw MaestroSetupFailure("fixture_delete_failed") + } + for (timer in timers) { + val detail = read(api.getCustomTimer(timer.customTimerId)) + if (detail.customTimerId != timer.customTimerId) throw MaestroSetupFailure("fixture_read_failed") + if (detail.customTimerName !in run.timerNames) continue + if (!api.deleteCustomTimer(timer.customTimerId).succeed) throw MaestroSetupFailure("fixture_delete_failed") + } + } + + private suspend fun exerciseRecords(run: MaestroOfflineRun): List { + val records = read(api.getExerciseList(run.startedAt.toLocalDate())) + if (records.any { it.exerciseId <= 0 || it.exerciseStartedAt.toLocalDate() != run.startedAt.toLocalDate() } || + records.map { it.exerciseId }.toSet().size != records.size + ) { + throw MaestroSetupFailure("fixture_read_failed") + } + return records.filter { it.exerciseTitle == run.exerciseTitle } + } + + private suspend fun timerSummaries() = + read(api.getCustomTimerList()).also { list -> + if (list.customTimerCount != list.customTimers.size || list.customTimers.any { it.customTimerId <= 0 } || + list.customTimers.map { it.customTimerId }.toSet().size != list.customTimers.size + ) { + throw MaestroSetupFailure("fixture_read_failed") + } + }.customTimers + + private fun read(response: BaseResponse): T { + if (!response.succeed) throw MaestroSetupFailure("fixture_read_failed") + return response.data ?: throw MaestroSetupFailure("fixture_read_failed") + } + + companion object { + private val FLOWS = setOf("offline-exercise-create", "offline-exercise-missing", "offline-timers-online") + + fun run(request: MaestroFeatureRequest): MaestroOfflineRun { + MaestroFeaturePolicy.validateIdentity(request.feature, request.flow, request.runId) + if (request.feature != "offline" || request.flow !in FLOWS || + request.parameters.keys().asSequence().toSet() != setOf("startedAt", "endedAt") + ) { + throw MaestroSetupFailure("invalid_feature_request") + } + return try { + MaestroOfflineRun( + request.runId, + LocalDateTime.parse(request.parameters.getString("startedAt")), + LocalDateTime.parse(request.parameters.getString("endedAt")), + ) + } catch (_: Exception) { + throw MaestroSetupFailure("invalid_feature_request") + } + } + + val STAGES = setOf("exercise-online", "timers-online", "timers-synced") + private const val UPLOAD_TIMEOUT_MS = 120_000L + + internal fun matchesTimer( + detail: GetCustomTimerDetailDTO, + id: Long, + name: String, + seconds: Int, + ): Boolean = + detail.customTimerId == id && detail.customTimerName == name && detail.customTimerStepCount == 1 && + detail.customTimerSteps.singleOrNull()?.let { + it.customTimerStepName == "offline-step" && it.customTimerStepOrder == 0 && it.customTimerStepTime == seconds + } == true + } + } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt index a17117b8..00b62242 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSessionBootstrap.kt @@ -1,6 +1,7 @@ package com.project200.undabang.e2e import android.net.Uri +import com.project200.data.api.ApiService import com.project200.domain.model.RegistrationStatus import com.project200.domain.repository.AuthRepository import com.project200.undabang.oauth.AuthManager @@ -25,6 +26,7 @@ internal class MaestroSessionBootstrap private val authStateManager: AuthStateManager, private val authManager: AuthManager, private val authRepository: AuthRepository, + private val apiService: ApiService, ) { suspend fun authenticate( state: AuthState, @@ -33,25 +35,46 @@ internal class MaestroSessionBootstrap val trusted = fetchTrustedConfiguration() validateState(state, trusted) val authorizationResponse = state.lastAuthorizationResponse ?: throw MaestroSetupFailure("untrusted_session") - val tokenResponse = state.lastTokenResponse ?: throw MaestroSetupFailure("untrusted_session") - val refreshToken = state.refreshToken?.takeIf { it.isNotBlank() } ?: throw MaestroSetupFailure("untrusted_session") - - // AppAuth는 마지막 응답과 별도로 최신 refreshToken을 저장하므로 응답의 이전 값을 쓰지 않습니다 - val restoredTokens = - TokenResponse.Builder(tokenResponse.request) - .setTokenType(tokenResponse.tokenType) - .setAccessToken(tokenResponse.accessToken) - .setAccessTokenExpirationTime(tokenResponse.accessTokenExpirationTime) - .setIdToken(tokenResponse.idToken) - .setRefreshToken(refreshToken) - .setScope(state.scope ?: tokenResponse.scope) - .setAdditionalParameters(tokenResponse.additionalParameters) - .build() + val restoredTokens = tokensForRestore(state) authRepository.clearSession() authStateManager.update(authorizationResponse, null) authStateManager.update(restoredTokens, null) + return refreshAndVerify(expectedMemberId) + } + + /** 실패한 오프라인 실행의 회원 경계와 미전송 행을 유지한 채 같은 계정만 복구합니다 */ + suspend fun authenticateForOfflineCleanup( + state: AuthState, + expectedMemberId: String, + ): String { + requireLocalMember(expectedMemberId) + val trusted = fetchTrustedConfiguration() + validateState(state, trusted) + if (state.refreshToken.isNullOrBlank()) throw MaestroSetupFailure("untrusted_session") + val current = currentState() + if (current.isAuthorized && !current.refreshToken.isNullOrBlank()) { + validateState(current, trusted) + } else { + val authorizationResponse = state.lastAuthorizationResponse ?: throw MaestroSetupFailure("untrusted_session") + val restoredTokens = tokensForRestore(state) + authRepository.clearTokens() + authStateManager.update(authorizationResponse, null) + authStateManager.update(restoredTokens, null) + } + return refreshAndVerifySameMember(expectedMemberId) + } + + /** 서버 관찰은 현재 세션을 검증하되 Room과 미전송 변경을 지우지 않습니다 */ + suspend fun verifyCurrent(expectedMemberId: String): String { + requireLocalMember(expectedMemberId) + val trusted = fetchTrustedConfiguration() + validateState(currentState(), trusted) + return refreshAndVerifySameMember(expectedMemberId) + } + + private suspend fun refresh() { // 만료 시각이나 가져온 ID 토큰을 믿지 않고 실제 DEV 서버에서 새 토큰을 발급받습니다 if (authManager.refreshAccessToken() !is TokenRefreshResult.Success) { throw MaestroSetupFailure("refresh_failed") @@ -60,6 +83,10 @@ internal class MaestroSessionBootstrap if (!current.isAuthorized || current.idToken.isNullOrBlank() || current.refreshToken.isNullOrBlank()) { throw MaestroSetupFailure("refresh_failed") } + } + + private suspend fun refreshAndVerify(expectedMemberId: String?): String { + refresh() if (authRepository.checkIsRegistered() != RegistrationStatus.Registered) { throw MaestroSetupFailure("registration_failed") } @@ -72,6 +99,39 @@ internal class MaestroSessionBootstrap return memberId } + private suspend fun refreshAndVerifySameMember(expectedMemberId: String): String { + refresh() + // 등록 확인 Repository는 회원 교체 시 Room을 지우므로 관찰·복구에서는 직접 조회합니다. + val response = apiService.getIsRegistered() + val memberId = + response.data?.takeIf { response.succeed && it.isRegistered }?.memberId?.takeIf { it.isNotBlank() } + ?: throw MaestroSetupFailure("registration_failed") + if (memberId != expectedMemberId) throw MaestroSetupFailure("member_mismatch") + requireLocalMember(expectedMemberId) + return memberId + } + + private suspend fun requireLocalMember(expectedMemberId: String) { + if (expectedMemberId.isBlank() || authRepository.getMemberId() != expectedMemberId) { + throw MaestroSetupFailure("member_mismatch") + } + } + + private fun tokensForRestore(state: AuthState): TokenResponse { + val tokenResponse = state.lastTokenResponse ?: throw MaestroSetupFailure("untrusted_session") + val refreshToken = state.refreshToken?.takeIf { it.isNotBlank() } ?: throw MaestroSetupFailure("untrusted_session") + // AppAuth는 마지막 응답과 별도로 최신 refreshToken을 저장하므로 응답의 이전 값을 쓰지 않습니다 + return TokenResponse.Builder(tokenResponse.request) + .setTokenType(tokenResponse.tokenType) + .setAccessToken(tokenResponse.accessToken) + .setAccessTokenExpirationTime(tokenResponse.accessTokenExpirationTime) + .setIdToken(tokenResponse.idToken) + .setRefreshToken(refreshToken) + .setScope(state.scope ?: tokenResponse.scope) + .setAdditionalParameters(tokenResponse.additionalParameters) + .build() + } + fun currentState(): AuthState = authStateManager.getCurrent() suspend fun clearSession() { @@ -82,6 +142,14 @@ internal class MaestroSessionBootstrap } } + suspend fun clearTokens() { + authRepository.clearTokens() + val state = authStateManager.getCurrent() + if (state.isAuthorized || state.refreshToken != null) { + throw MaestroSetupFailure("local_session_clear_failed") + } + } + private suspend fun fetchTrustedConfiguration(): AuthorizationServiceConfiguration { if (!OAuthBuildConfig.DEBUG || !DataBuildConfig.DEBUG || cognitoConfig.cognitoRegion.isBlank() || cognitoConfig.cognitoUserPoolId.isBlank() || diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt index 3ac8d148..1a3ac868 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupCoordinator.kt @@ -17,6 +17,7 @@ internal class MaestroSetupCoordinator private val sessionBootstrap: MaestroSessionBootstrap, private val fixtureCleaner: MaestroFixtureCleaner, private val featureFixtures: Map, + private val offlineFixtures: MaestroOfflineFixtures, ) { private val requestMutex = Mutex() @@ -24,8 +25,10 @@ internal class MaestroSetupCoordinator // Activity 종료로 refresh 콜백과 실패 정리가 교차하지 않도록 작업을 끝까지 직렬화합니다 withContext(NonCancellable + Dispatchers.IO) { requestMutex.withLock { + var preserveLocalDataOnFailure = false try { val request = protocol.consumeRequest() + preserveLocalDataOnFailure = request.preserveLocalDataOnFailure protocol.clearOutputs() when (request) { MaestroSetupRequest.Export -> { @@ -36,14 +39,20 @@ internal class MaestroSetupCoordinator is MaestroSetupRequest.FixtureOperation -> { val today = LocalDate.now() if (request.fixtureStartDate > today) throw MaestroSetupFailure("fixture_range_invalid") - sessionBootstrap.authenticate(request.authState, request.memberId) - // 인증과 대상 handler 확인을 모두 마친 뒤에만 서버 정리를 시작합니다. val feature = request.featureRequest + if (request.preserveLocalDataOnFailure) { + // 오프라인 복구는 인증을 바꾸기 전에 원 실행의 ownership을 요구합니다. + protocol.validateFeatureRecovery(feature, required = true) + sessionBootstrap.authenticateForOfflineCleanup(request.authState, request.memberId) + } else { + sessionBootstrap.authenticate(request.authState, request.memberId) + } + // 인증과 대상 handler 확인을 모두 마친 뒤에만 서버 정리를 시작합니다. val handler = feature?.let { featureFixtures[it.feature] ?: throw MaestroSetupFailure("feature_fixture_missing") } - protocol.validateFeatureRecovery(feature) + if (!request.preserveLocalDataOnFailure) protocol.validateFeatureRecovery(feature) val variables = if (request.operation == "prepare") { fixtureCleaner.clean(request.fixtureStartDate, today) @@ -62,17 +71,27 @@ internal class MaestroSetupCoordinator } protocol.writeStatus(if (request.operation == "prepare") "ready" else "cleaned", variables) } + is MaestroSetupRequest.OfflineObservation -> { + protocol.validateFeatureRecovery(request.featureRequest, required = true) + offlineFixtures.validateDate(request.run) + sessionBootstrap.verifyCurrent(request.memberId) + offlineFixtures.observe(request.run, request.stage) + protocol.writeStatus("observed") + } } } catch (failure: MaestroSetupFailure) { - failClosed(failure.reason) + failClosed(failure.reason, preserveLocalDataOnFailure) } catch (_: Exception) { // 예외 본문이나 서버 응답을 결과 파일과 로그로 전달하지 않습니다 - failClosed("setup_failed") + failClosed("setup_failed", preserveLocalDataOnFailure) } } } - private suspend fun failClosed(reason: String) { + private suspend fun failClosed( + reason: String, + preserveLocalDataOnFailure: Boolean, + ) { var failureReason = reason try { protocol.deleteRequest() @@ -80,7 +99,11 @@ internal class MaestroSetupCoordinator failureReason = "private_file_failed" } try { - sessionBootstrap.clearSession() + if (preserveLocalDataOnFailure) { + sessionBootstrap.clearTokens() + } else { + sessionBootstrap.clearSession() + } } catch (_: Exception) { failureReason = "local_session_clear_failed" } diff --git a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt index d27e97a3..4f74f06a 100644 --- a/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt +++ b/app/src/e2e/java/com/project200/undabang/e2e/MaestroSetupProtocol.kt @@ -11,6 +11,9 @@ import java.time.LocalDate import javax.inject.Inject internal sealed interface MaestroSetupRequest { + val preserveLocalDataOnFailure: Boolean + get() = false + data object Export : MaestroSetupRequest data class FixtureOperation( @@ -19,7 +22,19 @@ internal sealed interface MaestroSetupRequest { val memberId: String, val fixtureStartDate: LocalDate, val featureRequest: MaestroFeatureRequest? = null, - ) : MaestroSetupRequest + ) : MaestroSetupRequest { + override val preserveLocalDataOnFailure: Boolean + get() = operation == "cleanup" && featureRequest?.feature == "offline" + } + + data class OfflineObservation( + val memberId: String, + val run: MaestroOfflineRun, + val stage: String, + val featureRequest: MaestroFeatureRequest, + ) : MaestroSetupRequest { + override val preserveLocalDataOnFailure: Boolean = true + } } internal class MaestroSetupFailure(val reason: String) : Exception(reason) @@ -81,6 +96,23 @@ internal class MaestroSetupProtocol }, ) } + "offline-observe" -> { + requireKeys(json, setOf("operation", "memberId", "runId", "flow", "startedAt", "endedAt", "stage")) + val memberId = json.get("memberId") as? String + if (memberId.isNullOrBlank()) throw MaestroSetupFailure("invalid_request") + val feature = + MaestroFeatureRequest( + "offline", + json.getString("flow"), + json.getString("runId"), + memberId, + JSONObject().put("startedAt", json.getString("startedAt")).put("endedAt", json.getString("endedAt")), + ) + val run = MaestroOfflineFixtures.run(feature) + val stage = json.getString("stage") + if (stage !in MaestroOfflineFixtures.STAGES) throw MaestroSetupFailure("invalid_request") + MaestroSetupRequest.OfflineObservation(memberId, run, stage, feature) + } else -> throw MaestroSetupFailure("invalid_request") } } catch (failure: MaestroSetupFailure) { @@ -128,8 +160,14 @@ internal class MaestroSetupProtocol ) } - fun validateFeatureRecovery(request: MaestroFeatureRequest?) { - if (!hasFeatureRecovery()) return + fun validateFeatureRecovery( + request: MaestroFeatureRequest?, + required: Boolean = false, + ) { + if (!hasFeatureRecovery()) { + if (required) throw MaestroSetupFailure("feature_recovery_missing") + return + } if (request == null) throw MaestroSetupFailure("feature_recovery_pending") try { val json = diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFeatureFixtureTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFeatureFixtureTest.kt index 495a9986..622effd3 100644 --- a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFeatureFixtureTest.kt +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroFeatureFixtureTest.kt @@ -16,6 +16,7 @@ class MaestroFeatureFixtureTest { private val session = mockk(relaxed = true) private val cleaner = mockk(relaxed = true) private val fixture = mockk(relaxed = true) + private val offlineFixtures = mockk(relaxed = true) private val feature = MaestroFeatureRequest("profile", "profile-edit", "0123456789abcdef", "member", mockk()) private fun request(operation: String) = @@ -25,7 +26,7 @@ class MaestroFeatureFixtureTest { fun `handler가 없으면 인증 뒤 서버 정리 전에 실패한다`() = runTest { every { protocol.consumeRequest() } returns request("prepare") - MaestroSetupCoordinator(protocol, session, cleaner, emptyMap()).consumeRequest() + MaestroSetupCoordinator(protocol, session, cleaner, emptyMap(), offlineFixtures).consumeRequest() coVerify(exactly = 1) { session.authenticate(any(), "member") } coVerify(exactly = 0) { cleaner.clean(any(), any()) } verify { protocol.writeFailure("feature_fixture_missing") } @@ -36,7 +37,7 @@ class MaestroFeatureFixtureTest { runTest { every { protocol.consumeRequest() } returns request("prepare") coEvery { fixture.prepare(feature) } returns mapOf("PROFILE_NAME" to "test-name") - MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture)).consumeRequest() + MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture), offlineFixtures).consumeRequest() coVerifyOrder { session.authenticate(any(), "member") cleaner.clean(any(), any()) @@ -51,10 +52,13 @@ class MaestroFeatureFixtureTest { runTest { every { protocol.consumeRequest() } returns request("cleanup") coEvery { fixture.cleanup(feature) } throws MaestroSetupFailure("profile_restore_failed") - MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture)).consumeRequest() + MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture), offlineFixtures).consumeRequest() coVerify(exactly = 0) { cleaner.clean(any(), any()) } verify(exactly = 0) { protocol.completeFeatureRecovery() } verify { protocol.writeFailure("profile_restore_failed") } + coVerify(exactly = 1) { session.clearSession() } + coVerify(exactly = 0) { session.clearTokens() } + coVerify(exactly = 0) { session.authenticateForOfflineCleanup(any(), any()) } } @Test @@ -65,7 +69,7 @@ class MaestroFeatureFixtureTest { coEvery { cleaner.clean(any(), any()) } coAnswers { if (attempts++ == 0) throw MaestroSetupFailure("fixture_read_failed") } - val coordinator = MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture)) + val coordinator = MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture), offlineFixtures) coordinator.consumeRequest() @@ -103,7 +107,7 @@ class MaestroFeatureFixtureTest { coEvery { session.clearSession() } coAnswers { if (attempts++ == 0) throw MaestroSetupFailure("local_session_clear_failed") } - val coordinator = MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture)) + val coordinator = MaestroSetupCoordinator(protocol, session, cleaner, mapOf("profile" to fixture), offlineFixtures) coordinator.consumeRequest() diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt new file mode 100644 index 00000000..3109a251 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineFixturesTest.kt @@ -0,0 +1,255 @@ +package com.project200.undabang.e2e + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.datasource.FeatureFlagDataSource +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.CustomTimerDetailStepDTO +import com.project200.data.dto.CustomTimerSummaryDTO +import com.project200.data.dto.GetCustomTimerDetailDTO +import com.project200.data.dto.GetCustomTimerListDTO +import com.project200.data.dto.GetExerciseRecordData +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.domain.model.UpdateInfo +import com.project200.domain.repository.AppUpdateRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Test +import java.time.LocalDateTime + +class MaestroOfflineFixturesTest { + private val api = mockk() + private val flags = mockk() + private val updates = mockk() + private val fixtures = MaestroOfflineFixtures(api, flags, updates) + private val run = + MaestroOfflineRun( + "0123456789abcdef0123456789abcdef", + LocalDateTime.of(2026, 10, 15, 9, 0), + LocalDateTime.of(2026, 10, 15, 10, 0), + ) + + @Test + fun `실제 활성화된 캐시 플래그가 꺼져 있으면 준비를 거절한다`() = + runTest { + coEvery { updates.getUpdateInfo() } returns Result.success(mockk()) + every { flags.isOfflineCacheEnabled() } returns false + + expectFailure("offline_cache_flag_disabled") { fixtures.requireCacheEnabled() } + + coVerify(exactly = 1) { updates.getUpdateInfo() } + coVerify(exactly = 0) { api.getExerciseList(any()) } + } + + @Test + fun `서버 목록과 상세의 정확한 이름 시간 스텝을 직접 확인한다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.getCustomTimer(2) } returns success(timer(2, run.updatedTimer, 75)) + + fixtures.observe(run, "timers-synced") + + coVerify(exactly = 1) { api.getCustomTimerList() } + coVerify(exactly = 1) { api.getCustomTimer(1) } + coVerify(exactly = 1) { api.getCustomTimer(2) } + coVerify(exactly = 0) { api.postCustomTimer(any()) } + coVerify(exactly = 0) { api.putCustomTimer(any(), any()) } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `미전송 생성이나 삭제 잔존은 제한 시간 뒤 실제 실패다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.originalTimer, run.deletedTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + + coVerify(atLeast = 2) { api.getCustomTimerList() } + coVerify(exactly = 0) { api.postCustomTimer(any()) } + } + + @Test + fun `같은 고유 제목의 중복 서버 행은 업로드 성공으로 인정하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.createdTimer, run.updatedTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `수정 제목만 맞고 스텝 시간이 이전 값이면 성공하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.getCustomTimer(2) } returns success(timer(2, run.updatedTimer, 45)) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `서버 실패와 null 본문은 빈 목록이나 로컬 성공으로 바꾸지 않는다`() = + runTest { + for (response in listOf( + BaseResponse(false, "SERVER_ERROR", "private server diagnostic"), + BaseResponse(true, "OK", "", null), + )) { + coEvery { api.getCustomTimerList() } returns response + expectFailure("fixture_read_failed") { fixtures.observe(run, "timers-synced") } + } + } + + @Test + fun `UI로 만든 운동의 목록과 상세를 서버에서 독립 확인한다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(listOf(exercise(1, run.exerciseTitle))) + coEvery { api.getExerciseRecordDetail(1) } returns success(exerciseDetail(run.exerciseTitle)) + + fixtures.observe(run, "exercise-online") + + coVerify(exactly = 1) { api.getExerciseRecordDetail(1) } + coVerify(exactly = 0) { api.postExerciseRecord(any()) } + } + + @Test + fun `정리는 이번 UUID와 정확히 일치하는 운동과 타이머만 삭제한다`() = + runTest { + val other = "maestro-offline-ffffffffffffffffffffffffffffffff-create" + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns + success(listOf(exercise(1, run.exerciseTitle), exercise(2, "personal exercise"))) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, other, "personal timer") + coEvery { api.getExerciseRecordDetail(1) } returns success(exerciseDetail(run.exerciseTitle)) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.deleteExerciseRecord(1) } returns success(null) + coEvery { api.deleteCustomTimer(1) } returns success(null) + + fixtures.clean(run) + + coVerify(exactly = 1) { api.deleteExerciseRecord(1) } + coVerify(exactly = 1) { api.deleteCustomTimer(1) } + coVerify(exactly = 0) { api.deleteExerciseRecord(2) } + coVerify(exactly = 0) { api.deleteCustomTimer(2) } + coVerify(exactly = 0) { api.deleteCustomTimer(3) } + coVerify(exactly = 0) { api.deleteSimpleTimer(any()) } + } + + @Test + fun `모든 목록 조회 전에 삭제하지 않고 뒤 조회 실패 시 후보도 보존한다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(listOf(exercise(1, run.exerciseTitle))) + coEvery { api.getCustomTimerList() } returns BaseResponse(false, "SERVER_ERROR", "") + + expectFailure("fixture_read_failed") { fixtures.clean(run) } + + coVerify(exactly = 0) { api.deleteExerciseRecord(any()) } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `상세가 일반 제목으로 바뀐 타이머는 정리하지 않는다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, "personal timer", 45)) + + fixtures.clean(run) + + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + } + + @Test + fun `서버에 미저장 draft가 추가되면 두 저장값이 맞아도 성공하지 않는다`() = + runTest { + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer, run.updatedTimer, run.draftTimer) + + expectFailure("offline_upload_not_observed") { fixtures.observe(run, "timers-synced") } + } + + @Test + fun `삭제 실패는 정리 성공으로 숨기지 않는다`() = + runTest { + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns timerList(run.createdTimer) + coEvery { api.getCustomTimer(1) } returns success(timer(1, run.createdTimer, 45)) + coEvery { api.deleteCustomTimer(1) } returns BaseResponse(false, "SERVER_ERROR", "private diagnostic") + + expectFailure("fixture_delete_failed") { fixtures.clean(run) } + } + + @Test + fun `공통 feature 준비는 실행별 정리 뒤 실제 플래그를 검사하고 정리는 같은 요청을 재사용한다`() = + runTest { + val parameters = mockk() + every { parameters.keys() } answers { mutableListOf("startedAt", "endedAt").iterator() } + every { parameters.getString("startedAt") } returns run.startedAt.toString() + every { parameters.getString("endedAt") } returns run.endedAt.toString() + val request = MaestroFeatureRequest("offline", "offline-exercise-create", run.id, "member", parameters) + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns timerList() + coEvery { updates.getUpdateInfo() } returns Result.success(mockk()) + every { flags.isOfflineCacheEnabled() } returns true + + assertThat(fixtures.prepare(request)).isEmpty() + fixtures.cleanup(request) + + coVerifyOrder { + api.getExerciseList(run.startedAt.toLocalDate()) + api.getCustomTimerList() + updates.getUpdateInfo() + flags.isOfflineCacheEnabled() + api.getExerciseList(run.startedAt.toLocalDate()) + api.getCustomTimerList() + } + coVerify(exactly = 0) { api.postCustomTimer(any()) } + coVerify(exactly = 0) { api.putCustomTimer(any(), any()) } + } + + @Test + fun `다른 feature나 관찰 단계가 prepare로 들어오면 서버를 변경하지 않는다`() = + runTest { + for (request in listOf( + MaestroFeatureRequest("profile", "offline-exercise-create", run.id, "member", mockk()), + MaestroFeatureRequest("offline", "offline-timers-reconnect", run.id, "member", mockk()), + )) { + expectFailure("invalid_feature_request") { fixtures.prepare(request) } + } + coVerify(exactly = 0) { api.getExerciseList(any()) } + coVerify(exactly = 0) { api.getCustomTimerList() } + coVerify(exactly = 0) { updates.getUpdateInfo() } + } + + private fun timerList(vararg names: String) = + success(GetCustomTimerListDTO(names.size, names.mapIndexed { index, name -> CustomTimerSummaryDTO(index.toLong() + 1, name) })) + + private fun timer( + id: Long, + name: String, + seconds: Int, + ) = GetCustomTimerDetailDTO(id, name, 1, listOf(CustomTimerDetailStepDTO(100 + id, "offline-step", 0, seconds))) + + private fun exercise( + id: Long, + title: String, + ) = GetExerciseRecordListDto(id, title, "OTHER", run.startedAt, run.endedAt, null) + + private fun exerciseDetail(title: String) = GetExerciseRecordData(title, "", "OTHER", run.startedAt, run.endedAt, "", null) + + private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) + + private suspend fun expectFailure( + reason: String, + operation: suspend () -> Unit, + ) { + try { + operation() + throw AssertionError("실패할 서버 관찰이 성공했습니다") + } catch (failure: MaestroSetupFailure) { + assertThat(failure.reason).isEqualTo(reason) + } + } +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt new file mode 100644 index 00000000..da415c72 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroOfflineRecoveryTest.kt @@ -0,0 +1,523 @@ +package com.project200.undabang.e2e + +import android.app.Application +import android.content.Context +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.CustomTimerDetailStepDTO +import com.project200.data.dto.CustomTimerSummaryDTO +import com.project200.data.dto.GetCustomTimerDetailDTO +import com.project200.data.dto.GetCustomTimerListDTO +import com.project200.data.dto.GetIsRegisteredData +import com.project200.data.impl.AuthRepositoryImpl +import com.project200.data.local.PreferenceManager +import com.project200.domain.manager.SessionDataCleaner +import com.project200.undabang.oauth.AuthManager +import com.project200.undabang.oauth.AuthStateManager +import com.project200.undabang.oauth.TokenRefreshResult +import com.project200.undabang.oauth.config.CognitoConfig +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.AuthorizationServiceDiscovery +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.io.IOException +import java.time.LocalDate +import java.time.LocalDateTime + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE, application = Application::class) +class MaestroOfflineRecoveryTest { + private val context: Context = RuntimeEnvironment.getApplication() + private val api = mockk() + private val config = mockk() + private val stateManager = mockk() + private val authManager = mockk() + private val dataCleaner = mockk() + private val commonCleaner = mockk(relaxed = true) + private val preferences = PreferenceManager(context) + private val protocol = MaestroSetupProtocol(context) + private val offline = MaestroOfflineFixtures(api, mockk(), mockk()) + + // 실제 AuthRepository가 clearAll을 호출하면 pending 증거가 사라지는 경계를 감지합니다. + private val pending = mutableListOf("pending-create", "pending-update", "pending-delete") + private val run = + MaestroOfflineRun( + "0123456789abcdef0123456789abcdef", + LocalDateTime.of(2026, 1, 1, 9, 0), + LocalDateTime.of(2026, 1, 1, 10, 0), + ) + private val feature = + MaestroFeatureRequest( + "offline", + "offline-timers-online", + run.id, + MEMBER, + JSONObject().put("startedAt", run.startedAt.toString()).put("endedAt", run.endedAt.toString()), + ) + private val recoveryFile = File(context.filesDir, "maestro-feature-recovery.json") + private val resultFile = File(context.filesDir, "maestro-result.json") + private lateinit var trusted: AuthorizationServiceConfiguration + private lateinit var savedState: AuthState + private var current = AuthState() + private lateinit var bootstrap: MaestroSessionBootstrap + private lateinit var coordinator: MaestroSetupCoordinator + private lateinit var recoveryBytes: ByteArray + + @Before + fun setUp() { + protocol.deleteRequest() + protocol.clearOutputs() + protocol.completeFeatureRecovery() + preferences.saveMemberId(MEMBER) + every { config.cognitoRegion } returns "test-region" + every { config.cognitoUserPoolId } returns "test-pool" + every { config.cognitoAppClientId } returns CLIENT + every { config.issuerUri } returns ISSUER + every { config.redirectUri } returns "com.project200.undabang://callback" + trusted = + AuthorizationServiceConfiguration( + AuthorizationServiceDiscovery( + JSONObject() + .put("issuer", ISSUER) + .put("authorization_endpoint", "$ISSUER/authorize") + .put("token_endpoint", "$ISSUER/token") + .put("jwks_uri", "$ISSUER/keys") + .put("response_types_supported", JSONArray().put("code")) + .put("subject_types_supported", JSONArray().put("public")) + .put("id_token_signing_alg_values_supported", JSONArray().put("RS256")), + ), + ) + savedState = authorizedState() + mockkStatic(AuthorizationServiceConfiguration::class) + every { AuthorizationServiceConfiguration.fetchFromIssuer(any(), any()) } answers { + secondArg() + .onFetchConfigurationCompleted(trusted, null) + } + every { stateManager.getCurrent() } answers { current } + every { stateManager.clearAuthState() } answers { current = AuthState() } + every { stateManager.update(any(), any()) } answers { + current.update(firstArg(), null) + } + every { stateManager.update(any(), any()) } answers { + current.update(firstArg(), null) + } + coEvery { authManager.refreshAccessToken() } coAnswers { + val refreshed = tokens(checkNotNull(current.lastAuthorizationResponse), "refreshed-token") + current.update(refreshed, null) + TokenRefreshResult.Success(refreshed) + } + coEvery { dataCleaner.clearAll() } coAnswers { pending.clear() } + val repository = AuthRepositoryImpl(api, preferences, stateManager, dataCleaner, UnconfinedTestDispatcher()) + bootstrap = MaestroSessionBootstrap(config, stateManager, authManager, repository, api) + coordinator = MaestroSetupCoordinator(protocol, bootstrap, commonCleaner, mapOf("offline" to offline), offline) + coEvery { api.getIsRegistered() } returns success(GetIsRegisteredData(MEMBER, true)) + coEvery { api.getExerciseList(run.startedAt.toLocalDate()) } returns success(emptyList()) + coEvery { api.getCustomTimerList() } returns + success(GetCustomTimerListDTO(1, listOf(CustomTimerSummaryDTO(1, run.createdTimer)))) + coEvery { api.getCustomTimer(1) } returns + success(GetCustomTimerDetailDTO(1, run.createdTimer, 1, listOf(CustomTimerDetailStepDTO(2, "offline-step", 0, 45)))) + coEvery { api.deleteCustomTimer(1) } returns BaseResponse(false, "SERVER_ERROR", "private diagnostic") + protocol.markFeatureRecovery(feature) + recoveryBytes = recoveryFile.readBytes() + } + + @After + fun tearDown() { + unmockkStatic(AuthorizationServiceConfiguration::class) + protocol.deleteRequest() + protocol.clearOutputs() + protocol.completeFeatureRecovery() + preferences.clearMemberId() + } + + @Test + fun `실제 인증 저장소와 서버 삭제 실패를 거쳐도 pending과 회원과 원 ownership이 남는다`() = + runTest { + // 이전 실패로 토큰이 없어도 회원 경계는 남아 있어야 합니다. + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("fixture_delete_failed") + assertPreserved() + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + coVerify(exactly = 1) { api.deleteCustomTimer(1) } + coVerify(exactly = 0) { commonCleaner.clean(any(), any()) } + assertThat(current.isAuthorized).isFalse() + assertThat(current.refreshToken).isNull() + + // 보존된 회원과 동일한 자격 증명으로 재시도하면 실제 정리 완료 뒤에만 세션을 지웁니다. + coEvery { api.deleteCustomTimer(1) } returns success(null) + writeCleanup() + coordinator.consumeRequest() + + assertThat(result().getString("status")).isEqualTo("cleaned") + assertThat(preferences.getMemberId()).isNull() + assertThat(recoveryFile.exists()).isFalse() + assertThat(pending).containsExactly("pending-create", "pending-update", "pending-delete") + coVerify(exactly = 0) { dataCleaner.clearAll() } + coVerify(exactly = 2) { api.getIsRegistered() } + coVerify(exactly = 2) { api.deleteCustomTimer(1) } + coVerify(exactly = 1) { commonCleaner.clean(any(), any()) } + } + + @Test + fun `실제 등록 응답이 다른 회원이면 로컬 회원 교체나 서버 삭제를 허가하지 않는다`() = + runTest { + coEvery { api.getIsRegistered() } returns success(GetIsRegisteredData("other-member", true)) + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("member_mismatch") + assertPreserved() + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + assertNoFixtureCalls() + } + + @Test + fun `저장된 로컬 회원이 요청과 다르면 자격 증명을 복원하거나 갱신하지 않는다`() = + runTest { + preferences.saveMemberId("other-local-member") + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("member_mismatch") + assertPreserved("other-local-member") + coVerify(exactly = 0) { authManager.refreshAccessToken() } + coVerify(exactly = 0) { api.getIsRegistered() } + verify(exactly = 0) { stateManager.update(any(), any()) } + assertNoFixtureCalls() + } + + @Test + fun `cleanup ownership이 다른 실행이면 인증 복원보다 먼저 거절한다`() = + runTest { + writeCleanup(feature.copy(runId = "ffffffffffffffffffffffffffffffff")) + + coordinator.consumeRequest() + + assertFailure("feature_recovery_owner_mismatch") + assertPreserved() + coVerify(exactly = 0) { authManager.refreshAccessToken() } + coVerify(exactly = 0) { api.getIsRegistered() } + verify(exactly = 0) { stateManager.update(any(), any()) } + assertNoFixtureCalls() + } + + @Test + fun `cleanup ownership이 없으면 같은 로컬 회원만으로 서버 삭제를 허가하지 않는다`() = + runTest { + protocol.completeFeatureRecovery() + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("feature_recovery_missing") + assertThat(preferences.getMemberId()).isEqualTo(MEMBER) + assertThat(pending).hasSize(3) + coVerify(exactly = 0) { dataCleaner.clearAll() } + coVerify(exactly = 0) { authManager.refreshAccessToken() } + assertNoFixtureCalls() + } + + @Test + fun `등록 조회 실패와 누락 본문과 미가입은 로컬 회원이나 pending을 바꾸지 않는다`() = + runTest { + for (response in listOf( + BaseResponse(false, "SERVER_ERROR", "private diagnostic"), + BaseResponse(true, "OK", ""), + success(GetIsRegisteredData(MEMBER, false)), + success(GetIsRegisteredData("", true)), + )) { + coEvery { api.getIsRegistered() } returns response + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("registration_failed") + assertPreserved() + } + assertNoFixtureCalls() + } + + @Test + fun `등록 조회 예외도 인증 성공으로 대체하지 않고 복구 증거를 보존한다`() = + runTest { + coEvery { api.getIsRegistered() } throws IOException("private diagnostic") + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("setup_failed") + assertPreserved() + assertThat(current.isAuthorized).isFalse() + assertNoFixtureCalls() + } + + @Test + fun `토큰 갱신 실패 응답이나 취소는 등록 조회와 서버 삭제를 허가하지 않는다`() = + runTest { + coEvery { authManager.refreshAccessToken() } returns TokenRefreshResult.NoRefreshToken + writeCleanup() + coordinator.consumeRequest() + assertFailure("refresh_failed") + assertPreserved() + + coEvery { authManager.refreshAccessToken() } throws CancellationException("private diagnostic") + writeCleanup() + coordinator.consumeRequest() + assertFailure("setup_failed") + assertPreserved() + coVerify(exactly = 0) { api.getIsRegistered() } + assertNoFixtureCalls() + } + + @Test + fun `갱신 성공 응답만 있고 현재 토큰이 쓸 수 없으면 등록이나 삭제를 하지 않는다`() = + runTest { + coEvery { authManager.refreshAccessToken() } coAnswers { + current = AuthState() + TokenRefreshResult.Success(checkNotNull(savedState.lastTokenResponse)) + } + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("refresh_failed") + assertPreserved() + coVerify(exactly = 0) { api.getIsRegistered() } + assertNoFixtureCalls() + } + + @Test + fun `다른 client의 가져온 인증은 신뢰 검증에서 실패하고 회원을 지우지 않는다`() = + runTest { + writeCleanup(state = authorizedState("foreign-client")) + + coordinator.consumeRequest() + + assertFailure("untrusted_session") + assertPreserved() + coVerify(exactly = 0) { authManager.refreshAccessToken() } + coVerify(exactly = 0) { api.getIsRegistered() } + assertNoFixtureCalls() + } + + @Test + fun `유효한 현재 인증은 cleanup에서 재가져오지 않고 실제 갱신과 등록 검증을 거친다`() = + runTest { + current = authorizedState() + writeCleanup() + + coordinator.consumeRequest() + + assertFailure("fixture_delete_failed") + assertPreserved() + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + verify(exactly = 0) { stateManager.update(any(), any()) } + verify(exactly = 0) { stateManager.update(any(), any()) } + } + + @Test + fun `관찰 중 실제 서버 읽기 실패도 회원과 pending과 ownership을 보존한다`() = + runTest { + current = authorizedState() + coEvery { api.getCustomTimerList() } returns BaseResponse(false, "SERVER_ERROR", "private diagnostic") + writeObservation() + + coordinator.consumeRequest() + + assertFailure("fixture_read_failed") + assertPreserved() + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + coVerify(exactly = 0) { commonCleaner.clean(any(), any()) } + verify(exactly = 0) { stateManager.update(any(), any()) } + verify(exactly = 0) { stateManager.update(any(), any()) } + assertThat(current.isAuthorized).isFalse() + } + + @Test + fun `관찰의 실제 등록 응답이 다르면 로컬 회원을 교체하지 않고 관찰을 거절한다`() = + runTest { + current = authorizedState() + coEvery { api.getIsRegistered() } returns success(GetIsRegisteredData("other-member", true)) + writeObservation() + + coordinator.consumeRequest() + + assertFailure("member_mismatch") + assertPreserved() + coVerify(exactly = 1) { api.getIsRegistered() } + assertNoFixtureCalls() + } + + @Test + fun `정상 관찰은 현재 인증을 실제 검증하고 회원과 pending과 ownership을 유지한다`() = + runTest { + current = authorizedState() + coEvery { api.getCustomTimerList() } returns + success( + GetCustomTimerListDTO( + 2, + listOf(CustomTimerSummaryDTO(1, run.createdTimer), CustomTimerSummaryDTO(2, run.updatedTimer)), + ), + ) + coEvery { api.getCustomTimer(2) } returns + success( + GetCustomTimerDetailDTO( + 2, + run.updatedTimer, + 1, + listOf(CustomTimerDetailStepDTO(3, "offline-step", 0, 75)), + ), + ) + writeObservation() + + coordinator.consumeRequest() + + assertThat(result().getString("status")).isEqualTo("observed") + assertPreserved() + assertThat(current.isAuthorized).isTrue() + assertThat(current.refreshToken).isEqualTo("refreshed-token") + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + coVerify(exactly = 0) { commonCleaner.clean(any(), any()) } + verify(exactly = 0) { stateManager.update(any(), any()) } + verify(exactly = 0) { stateManager.update(any(), any()) } + verify(exactly = 0) { stateManager.clearAuthState() } + } + + @Test + fun `일반 인증은 기존 회원 초기화와 등록 저장소의 계정 경계 정리를 유지한다`() = + runTest { + val memberId = bootstrap.authenticate(savedState, MEMBER) + + assertThat(memberId).isEqualTo(MEMBER) + assertThat(preferences.getMemberId()).isEqualTo(MEMBER) + assertThat(pending).isEmpty() + coVerify(exactly = 1) { dataCleaner.clearAll() } + coVerify(exactly = 1) { authManager.refreshAccessToken() } + coVerify(exactly = 1) { api.getIsRegistered() } + } + + private fun authorizedState(client: String = CLIENT): AuthState { + val request = + AuthorizationRequest.Builder(trusted, client, ResponseTypeValues.CODE, Uri.parse(config.redirectUri)) + .setScope("openid") + .build() + val response = AuthorizationResponse.Builder(request).setAuthorizationCode("test-code").build() + return AuthState().apply { + update(response, null) + update(tokens(response, "saved-refresh-token"), null) + } + } + + private fun tokens( + response: AuthorizationResponse, + refreshToken: String, + ): TokenResponse = + TokenResponse.Builder(response.createTokenExchangeRequest()) + .setTokenType("Bearer") + .setAccessToken("test-access-token") + .setIdToken("test-id-token") + .setRefreshToken(refreshToken) + .build() + + private fun writeCleanup( + owner: MaestroFeatureRequest = feature, + state: AuthState = savedState, + ) { + File(context.filesDir, "maestro-request.json").writeText( + JSONObject() + .put("operation", "cleanup") + .put("authState", state.jsonSerialize()) + .put("memberId", owner.memberId) + .put("fixtureStartDate", LocalDate.of(2026, 1, 1).toString()) + .put("feature", owner.feature) + .put("flow", owner.flow) + .put("runId", owner.runId) + .put("parameters", owner.parameters) + .toString(), + ) + } + + private fun writeObservation() { + File(context.filesDir, "maestro-request.json").writeText( + JSONObject() + .put("operation", "offline-observe") + .put("memberId", MEMBER) + .put("runId", run.id) + .put("flow", feature.flow) + .put("startedAt", run.startedAt.toString()) + .put("endedAt", run.endedAt.toString()) + .put("stage", "timers-synced") + .toString(), + ) + } + + private fun assertFailure(reason: String) { + val result = result() + assertThat(result.getString("status")).isEqualTo("failed") + assertThat(result.getString("reason")).isEqualTo(reason) + } + + private fun assertPreserved(member: String = MEMBER) { + assertThat(preferences.getMemberId()).isEqualTo(member) + assertThat(pending).containsExactly("pending-create", "pending-update", "pending-delete") + assertThat(recoveryFile.readBytes()).isEqualTo(recoveryBytes) + coVerify(exactly = 0) { dataCleaner.clearAll() } + } + + private fun assertNoFixtureCalls() { + coVerify(exactly = 0) { api.getExerciseList(any()) } + coVerify(exactly = 0) { api.getCustomTimerList() } + coVerify(exactly = 0) { api.deleteExerciseRecord(any()) } + coVerify(exactly = 0) { api.deleteCustomTimer(any()) } + coVerify(exactly = 0) { commonCleaner.clean(any(), any()) } + } + + private fun result(): JSONObject = JSONObject(resultFile.readText()) + + private fun success(data: T): BaseResponse = BaseResponse(true, "OK", "", data) + + private companion object { + const val MEMBER = "dedicated-member" + const val CLIENT = "test-client" + const val ISSUER = "https://dev-auth.example.test/pool" + } +} diff --git a/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt new file mode 100644 index 00000000..07710569 --- /dev/null +++ b/app/src/testE2e/java/com/project200/undabang/e2e/MaestroSetupCoordinatorTest.kt @@ -0,0 +1,193 @@ +package com.project200.undabang.e2e + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +class MaestroSetupCoordinatorTest { + private val protocol = mockk(relaxed = true) + private val session = mockk(relaxed = true) + private val legacyCleaner = mockk(relaxed = true) + private val fixtures = mockk(relaxed = true) + private val coordinator = MaestroSetupCoordinator(protocol, session, legacyCleaner, mapOf("offline" to fixtures), fixtures) + private val run = + MaestroOfflineRun( + "0123456789abcdef0123456789abcdef", + LocalDateTime.of(2026, 10, 15, 9, 0), + LocalDateTime.of(2026, 10, 15, 10, 0), + ) + private val feature = MaestroFeatureRequest("offline", "offline-timers-online", run.id, "dedicated-member", mockk()) + + @Test + fun `업로드 관찰은 ownership과 현재 세션만 검증하고 인증 복원이나 Room 초기화를 수행하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineObservation("dedicated-member", run, "timers-synced", feature) + + coordinator.consumeRequest() + + coVerifyOrder { + protocol.validateFeatureRecovery(feature, required = true) + session.verifyCurrent("dedicated-member") + fixtures.observe(run, "timers-synced") + protocol.writeStatus("observed") + } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { session.clearSession() } + coVerify(exactly = 0) { session.authenticateForOfflineCleanup(any(), any()) } + coVerify(exactly = 0) { fixtures.clean(any()) } + coVerify(exactly = 0) { legacyCleaner.clean(any(), any()) } + } + + @Test + fun `오프라인 준비는 공통 인증과 정리와 복구 표식 뒤에 handler를 실행한다`() = + runTest { + val auth = mockk() + every { protocol.consumeRequest() } returns + MaestroSetupRequest.FixtureOperation("prepare", auth, "dedicated-member", LocalDate.of(2026, 1, 1), feature) + coEvery { fixtures.prepare(feature) } returns emptyMap() + + coordinator.consumeRequest() + + coVerifyOrder { + session.authenticate(auth, "dedicated-member") + protocol.validateFeatureRecovery(feature) + legacyCleaner.clean(any(), any()) + protocol.markFeatureRecovery(feature) + fixtures.prepare(feature) + protocol.writeStatus("ready", emptyMap()) + } + } + + @Test + fun `계정 불일치면 서버 관찰과 삭제를 수행하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineObservation("wrong-member", run, "timers-synced", feature.copy(memberId = "wrong-member")) + coEvery { session.verifyCurrent("wrong-member") } throws MaestroSetupFailure("member_mismatch") + + coordinator.consumeRequest() + + coVerify(exactly = 0) { fixtures.observe(any(), any()) } + coVerify(exactly = 0) { fixtures.clean(any()) } + verify(exactly = 1) { protocol.writeFailure("member_mismatch") } + coVerify(exactly = 1) { session.clearTokens() } + coVerify(exactly = 0) { session.clearSession() } + verify(exactly = 0) { protocol.writeStatus(any(), any()) } + } + + @Test + fun `다른 기능의 미복구 ownership은 관찰 전에 거절한다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.OfflineObservation("dedicated-member", run, "timers-synced", feature) + every { protocol.validateFeatureRecovery(feature, required = true) } throws + MaestroSetupFailure("feature_recovery_owner_mismatch") + + coordinator.consumeRequest() + + coVerify(exactly = 0) { session.verifyCurrent(any()) } + coVerify(exactly = 0) { fixtures.observe(any(), any()) } + verify { protocol.writeFailure("feature_recovery_owner_mismatch") } + verify(exactly = 0) { protocol.completeFeatureRecovery() } + coVerify(exactly = 1) { session.clearTokens() } + coVerify(exactly = 0) { session.clearSession() } + } + + @Test + fun `오프라인 정리가 실패하면 일반 정리와 복구 완료를 실행하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.FixtureOperation("cleanup", mockk(), "dedicated-member", LocalDate.of(2026, 1, 1), feature) + coEvery { fixtures.cleanup(feature) } throws MaestroSetupFailure("fixture_delete_failed") + + coordinator.consumeRequest() + + coVerifyOrder { + protocol.validateFeatureRecovery(feature, required = true) + session.authenticateForOfflineCleanup(any(), "dedicated-member") + fixtures.cleanup(feature) + session.clearTokens() + protocol.writeFailure("fixture_delete_failed") + } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { session.clearSession() } + coVerify(exactly = 0) { legacyCleaner.clean(any(), any()) } + verify(exactly = 0) { protocol.completeFeatureRecovery() } + verify(exactly = 0) { protocol.writeStatus(any(), any()) } + verify { protocol.writeFailure("fixture_delete_failed") } + } + + @Test + fun `오프라인 cleanup은 같은 계정 검증과 두 정리를 마친 뒤 세션과 ownership을 완료한다`() = + runTest { + val auth = mockk() + every { protocol.consumeRequest() } returns + MaestroSetupRequest.FixtureOperation("cleanup", auth, "dedicated-member", LocalDate.of(2026, 1, 1), feature) + + coordinator.consumeRequest() + + coVerifyOrder { + protocol.validateFeatureRecovery(feature, required = true) + session.authenticateForOfflineCleanup(auth, "dedicated-member") + fixtures.cleanup(feature) + legacyCleaner.clean(any(), any()) + session.clearSession() + protocol.completeFeatureRecovery() + protocol.writeStatus("cleaned", emptyMap()) + } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { session.clearTokens() } + } + + @Test + fun `파싱된 오프라인 관찰과 cleanup은 결과 파일 초기화 실패부터 회원 보존 정책을 적용한다`() = + runTest { + val requests = + listOf( + MaestroSetupRequest.OfflineObservation("dedicated-member", run, "timers-synced", feature), + MaestroSetupRequest.FixtureOperation("cleanup", mockk(), "dedicated-member", LocalDate.of(2026, 1, 1), feature), + ) + every { protocol.clearOutputs() } throws MaestroSetupFailure("private_file_failed") + + for (request in requests) { + every { protocol.consumeRequest() } returns request + coordinator.consumeRequest() + } + + coVerify(exactly = 2) { session.clearTokens() } + coVerify(exactly = 0) { session.clearSession() } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { session.authenticateForOfflineCleanup(any(), any()) } + coVerify(exactly = 0) { session.verifyCurrent(any()) } + coVerify(exactly = 0) { fixtures.cleanup(any()) } + coVerify(exactly = 0) { fixtures.observe(any(), any()) } + verify(exactly = 0) { protocol.completeFeatureRecovery() } + verify(exactly = 2) { protocol.writeFailure("private_file_failed") } + } + + @Test + fun `오프라인 cleanup은 ownership이 없으면 인증을 바꾸거나 정리하지 않는다`() = + runTest { + every { protocol.consumeRequest() } returns + MaestroSetupRequest.FixtureOperation("cleanup", mockk(), "dedicated-member", LocalDate.of(2026, 1, 1), feature) + every { protocol.validateFeatureRecovery(feature, required = true) } throws MaestroSetupFailure("feature_recovery_missing") + + coordinator.consumeRequest() + + coVerify(exactly = 0) { session.authenticateForOfflineCleanup(any(), any()) } + coVerify(exactly = 0) { session.authenticate(any(), any()) } + coVerify(exactly = 0) { fixtures.cleanup(any()) } + coVerify(exactly = 0) { legacyCleaner.clean(any(), any()) } + coVerify(exactly = 1) { session.clearTokens() } + coVerify(exactly = 0) { session.clearSession() } + verify { protocol.writeFailure("feature_recovery_missing") } + } +} diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt index cfcfd92f..09c1946f 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainFragment.kt @@ -18,6 +18,7 @@ import com.kizitonwose.calendar.view.ViewContainer import com.project200.common.constants.RuleConstants.SCORE_HIGH_LEVEL import com.project200.common.constants.RuleConstants.SCORE_MIDDLE_LEVEL import com.project200.common.utils.CommonDateTimeFormatters.YYYY_M_KR +import com.project200.domain.model.BaseResult import com.project200.presentation.base.BindingFragment import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.CalendarDayLayoutBinding @@ -97,6 +98,7 @@ class ExerciseMainFragment : BindingFragment(R.layo selectedIv.isVisible = false exerciseCompleteIv.apply { isVisible = false + contentDescription = null animate().cancel() // 진행 중인 애니메이션이 있다면 취소 alpha = 1f } @@ -123,6 +125,7 @@ class ExerciseMainFragment : BindingFragment(R.layo if (exerciseCompleteDates.contains(data.date)) { exerciseCompleteIv.apply { alpha = 0f + contentDescription = getString(R.string.exercise_calendar_record, data.date.toString()) isVisible = true animate().alpha(1f).setDuration(300).start() } @@ -260,7 +263,6 @@ class ExerciseMainFragment : BindingFragment(R.layo updateListSelectors() exerciseAdapter.submitList(list) { if (submissionId == listSubmissionId && owner.lifecycle.currentState != Lifecycle.State.DESTROYED) { - binding.exerciseListEmptyTv.isVisible = list.isNullOrEmpty() binding.exerciseListRv.doOnNextLayout { if (submissionId == listSubmissionId && owner.lifecycle.currentState != Lifecycle.State.DESTROYED) { isListCommitted = true @@ -275,16 +277,31 @@ class ExerciseMainFragment : BindingFragment(R.layo viewModel.isExerciseListLoaded.observe(viewLifecycleOwner) { updateListSelectors() } + viewModel.exerciseListError.observe(viewLifecycleOwner) { + updateListSelectors() + } } private fun updateListSelectors() { - // 같은 내용의 목록도 현재 제출의 비동기 Diff가 반영된 뒤에만 완료 상태를 노출합니다. - val isLoaded = viewModel.isExerciseListLoaded.value == true && isListCommitted + // 성공 표시는 어댑터 반영을 기다리지만, 최신 요청의 오류는 목록 제출 없이도 완료 상태입니다. + val state = + exerciseListAccessibilityState( + isListCommitted = isListCommitted, + isListLoaded = viewModel.isExerciseListLoaded.value == true, + isListEmpty = exerciseAdapter.currentList.isEmpty(), + error = viewModel.exerciseListError.value, + ) val date = viewModel.selectedDate.value.toString() binding.exerciseListRv.contentDescription = - if (isLoaded && exerciseAdapter.currentList.isNotEmpty()) getString(R.string.exercise_list_loaded, date) else null + if (state == ExerciseListAccessibilityState.CONTENT) getString(R.string.exercise_list_loaded, date) else null + binding.exerciseListEmptyTv.isVisible = + state == ExerciseListAccessibilityState.EMPTY || state == ExerciseListAccessibilityState.NETWORK_ERROR binding.exerciseListEmptyTv.contentDescription = - if (isLoaded && exerciseAdapter.currentList.isEmpty()) getString(R.string.exercise_empty_loaded, date) else null + when (state) { + ExerciseListAccessibilityState.EMPTY -> getString(R.string.exercise_empty_loaded, date) + ExerciseListAccessibilityState.NETWORK_ERROR -> getString(R.string.exercise_list_network_error, date) + else -> null + } } inner class DayViewContainer(val binding: CalendarDayLayoutBinding) : @@ -307,3 +324,23 @@ class ExerciseMainFragment : BindingFragment(R.layo viewModel.refreshData() } } + +internal enum class ExerciseListAccessibilityState { + NOT_READY, + CONTENT, + EMPTY, + NETWORK_ERROR, +} + +internal fun exerciseListAccessibilityState( + isListCommitted: Boolean, + isListLoaded: Boolean, + isListEmpty: Boolean, + error: BaseResult.Error?, +): ExerciseListAccessibilityState = + when { + error?.errorCode == "NETWORK_ERROR" -> ExerciseListAccessibilityState.NETWORK_ERROR + !isListCommitted || !isListLoaded -> ExerciseListAccessibilityState.NOT_READY + isListEmpty -> ExerciseListAccessibilityState.EMPTY + else -> ExerciseListAccessibilityState.CONTENT + } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt index d8f2fe8a..795a5c63 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/main/ExerciseMainViewModel.kt @@ -63,6 +63,8 @@ class ExerciseMainViewModel // 초기값과 조회 실패가 삭제 검증의 빈 목록 선택자로 노출되지 않게 합니다. private val _isExerciseListLoaded = MutableLiveData(false) val isExerciseListLoaded: LiveData = _isExerciseListLoaded + private val _exerciseListError = MutableLiveData(null) + val exerciseListError: LiveData = _exerciseListError private var exerciseListRequestId = 0L private val expectedScoreInfo = MutableLiveData() @@ -106,6 +108,7 @@ class ExerciseMainViewModel private fun loadExercisesForDate(date: LocalDate) { val requestId = ++exerciseListRequestId _isExerciseListLoaded.value = false + _exerciseListError.value = null viewModelScope.launch { // 이전 요청의 완료 상태가 새 응답의 목록 관찰자에 전달되지 않게 먼저 해제합니다. val result = getExerciseRecordListUseCase(date) @@ -117,6 +120,9 @@ class ExerciseMainViewModel } is BaseResult.Error -> { _exerciseList.value = emptyList() // 실패 시 빈 리스트 + if (requestId == exerciseListRequestId && date == _selectedDate.value) { + _exerciseListError.value = result + } _toastMessage.value = result.message ?: "기록을 불러오는데 실패했습니다." } } diff --git a/feature/exercise/src/main/res/values/strings.xml b/feature/exercise/src/main/res/values/strings.xml index 08c9e7de..80268dfc 100644 --- a/feature/exercise/src/main/res/values/strings.xml +++ b/feature/exercise/src/main/res/values/strings.xml @@ -108,4 +108,6 @@ 미니멀 테마 사진 편집 공유하려면 사진이 필요해요 + 운동 기록이 있는 날짜: %1$s + %1$s 운동 기록 목록 네트워크 오류 \ No newline at end of file diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt new file mode 100644 index 00000000..f017a6ac --- /dev/null +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseListAccessibilityStateTest.kt @@ -0,0 +1,58 @@ +package com.project200.feature.exercise + +import com.google.common.truth.Truth.assertThat +import com.project200.domain.model.BaseResult +import com.project200.feature.exercise.main.ExerciseListAccessibilityState +import com.project200.feature.exercise.main.exerciseListAccessibilityState +import org.junit.Test + +class ExerciseListAccessibilityStateTest { + @Test + fun `첫 요청 네트워크 실패는 목록 제출이나 레이아웃 완료 없이 오류로 노출한다`() { + val state = + exerciseListAccessibilityState( + isListCommitted = false, + isListLoaded = false, + isListEmpty = true, + error = BaseResult.Error("NETWORK_ERROR", "network"), + ) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NETWORK_ERROR) + } + + @Test + fun `최신 네트워크 실패는 이전 어댑터 행이 남아 있어도 오류로 노출한다`() { + val state = + exerciseListAccessibilityState( + isListCommitted = false, + isListLoaded = false, + isListEmpty = false, + error = BaseResult.Error("NETWORK_ERROR", "network"), + ) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NETWORK_ERROR) + } + + @Test + fun `성공한 빈 목록은 어댑터 반영 전에는 성공 상태를 노출하지 않는다`() { + assertThat(exerciseListAccessibilityState(false, true, true, null)) + .isEqualTo(ExerciseListAccessibilityState.NOT_READY) + assertThat(exerciseListAccessibilityState(true, true, true, null)) + .isEqualTo(ExerciseListAccessibilityState.EMPTY) + } + + @Test + fun `성공한 실제 행도 최신 조회와 어댑터 반영이 모두 끝나야 노출한다`() { + assertThat(exerciseListAccessibilityState(true, false, false, null)) + .isEqualTo(ExerciseListAccessibilityState.NOT_READY) + assertThat(exerciseListAccessibilityState(true, true, false, null)) + .isEqualTo(ExerciseListAccessibilityState.CONTENT) + } + + @Test + fun `일반 서버 오류는 네트워크 실패로 바꾸지 않는다`() { + val state = exerciseListAccessibilityState(false, false, true, BaseResult.Error("SERVER_ERROR", "server")) + + assertThat(state).isEqualTo(ExerciseListAccessibilityState.NOT_READY) + } +} diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt index ceb1ceda..aaca13f1 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseMainViewModelTest.kt @@ -530,4 +530,84 @@ class ExerciseMainViewModelTest { viewModel.exerciseList.removeObserver(observer) } } + + @Test + fun `운동 목록 네트워크 오류는 성공한 빈 목록과 구분하고 새 요청에서 해제한다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } returns BaseResult.Error("NETWORK_ERROR", "network") + + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value?.errorCode).isEqualTo("NETWORK_ERROR") + assertThat(viewModel.isExerciseListLoaded.value).isFalse() + + val response = CompletableDeferred>>() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } coAnswers { response.await() } + viewModel.refreshData() + assertThat(viewModel.exerciseListError.value).isNull() + testDispatcher.scheduler.runCurrent() + assertThat(viewModel.exerciseListError.value).isNull() + + response.complete(BaseResult.Success(emptyList())) + testDispatcher.scheduler.advanceUntilIdle() + assertThat(viewModel.exerciseListError.value).isNull() + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + } + + @Test + fun `점수 조회 네트워크 토스트만으로 운동 목록 오류 상태를 만들지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { mockGetScoreUseCase.invoke() } returns BaseResult.Error("NETWORK_ERROR", "score network") + + viewModel.refreshData() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value).isNull() + assertThat(viewModel.isExerciseListLoaded.value).isTrue() + } + + @Test + fun `이전 날짜에서 늦게 온 네트워크 오류는 현재 날짜 오류로 노출하지 않는다`() = + runTest { + testDispatcher.scheduler.advanceUntilIdle() + val previous = CompletableDeferred>>() + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } coAnswers { previous.await() } + viewModel.refreshData() + testDispatcher.scheduler.runCurrent() + + viewModel.onDateSelected(today.minusDays(1)) + testDispatcher.scheduler.runCurrent() + previous.complete(BaseResult.Error("NETWORK_ERROR", "old date network")) + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.selectedDate.value).isEqualTo(today.minusDays(1)) + assertThat(viewModel.exerciseListError.value).isNull() + } + + @Test + fun `첫 목록 요청의 실패도 추가 새로고침 없이 오류 객체로 노출한다`() = + runTest { + // 공통 설정 인스턴스의 초기 요청을 먼저 완료합니다. + testDispatcher.scheduler.advanceUntilIdle() + + val failure = BaseResult.Error("NETWORK_ERROR", "first request") + coEvery { mockGetExerciseRecordListUseCase.invoke(today) } returns failure + viewModel = + ExerciseMainViewModel( + mockGetExerciseCountUseCase, + mockGetScoreUseCase, + mockGetScorePolicyUseCase, + mockGetExerciseRecordListUseCase, + mockGetExpectedScoreInfoUseCase, + mockClockProvider, + ) + + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.exerciseListError.value).isEqualTo(failure) + assertThat(viewModel.isExerciseListLoaded.value).isFalse() + } } diff --git a/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt index aec6f509..0adfe9e2 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/TimerListScreen.kt @@ -57,7 +57,8 @@ fun TimerListScreen( modifier .fillMaxSize() .background(ColorBackground) - .semantics { testTagsAsResourceId = true }, + .semantics { testTagsAsResourceId = true } + .testTag("custom_timer_list"), ) { UndabangTopBar( title = stringResource(R.string.timer_title), diff --git a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt index 16e149bf..29c46d9b 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerFormScreen.kt @@ -83,7 +83,7 @@ fun CustomTimerFormScreen( modifier: Modifier = Modifier, ) { UndabangScaffold( - modifier = modifier.semantics { testTagsAsResourceId = true }, + modifier = modifier.semantics { testTagsAsResourceId = true }.testTag("custom_timer_form"), topBar = { UndabangTopBar( title = diff --git a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt index 87e966b8..21d55c3a 100644 --- a/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt +++ b/feature/timer/src/main/java/com/project200/feature/timer/custom/CustomTimerScreen.kt @@ -82,7 +82,7 @@ fun CustomTimerScreen( modifier: Modifier = Modifier, ) { UndabangScaffold( - modifier = modifier.semantics { testTagsAsResourceId = true }, + modifier = modifier.semantics { testTagsAsResourceId = true }.testTag("custom_timer_detail"), topBar = { UndabangTopBar( title = title,