From c39acfaaf51cb94ee3963ffa81dd889509fa996e Mon Sep 17 00:00:00 2001 From: OziinG Date: Wed, 7 Oct 2026 01:55:29 +0900 Subject: [PATCH 01/10] Keep driver actions bound to current server authority Unresolved notification clicks and small user commands survive recovery without converting reads into business events or applying stale work to another assignment. Constraint: PR483 is unmerged and undeployed; D05 and Issue62 remain gates. Rejected: Legacy start fallback after unknown atomic result | can duplicate partial business events. Confidence: high Scope-risk: moderate Directive: Keep new APIs OFF until server, policy, security and device gates are cleared. Tested: Typecheck, 253 tests, lint, Expo alignment, Android Hermes export, diff check and independent review. Not-tested: Real FCM, native candidate installation, production server integration and iOS. --- .env.example | 3 + docs/driver-operational-notifications.md | 56 ++ docs/driver-operational-verification.md | 76 +++ docs/project-brief.md | 8 + docs/technology-stack.md | 12 + package-lock.json | 135 ++--- package.json | 10 +- src/api/dsvDriverEvents.test.ts | 15 + src/api/dsvDriverEvents.ts | 13 +- src/api/dsvDriverOperational.test.ts | 266 +++++++++ src/api/dsvDriverOperational.ts | 338 ++++++++++++ src/api/dsvDriverPushToken.test.ts | 69 ++- src/api/dsvDriverPushToken.ts | 67 ++- src/app/AppRoot.tsx | 315 ++++++++++- src/app/driverNotificationAppRoot.test.ts | 510 ++++++++++++++++++ src/app/driverNotificationNavigation.test.ts | 35 ++ src/app/driverNotificationNavigation.ts | 32 ++ src/app/driverOperationalFlow.test.ts | 84 +++ src/app/driverPushNotificationWiring.test.ts | 44 +- src/app/driverWorkspaceReads.test.ts | 425 +++++++++++++++ src/app/internalScreens.test.ts | 9 +- src/config/driverOperational.ts | 3 + .../delivery/driverCommandQueue.test.ts | 430 +++++++++++++++ src/domain/delivery/driverCommandQueue.ts | 411 ++++++++++++++ .../driverNotificationRecovery.test.ts | 209 +++++++ .../driverNotificationRecovery.ts | 166 ++++++ .../driverPushNotification.test.ts | 43 +- .../notifications/driverPushNotification.ts | 80 ++- .../expoDriverNotificationService.test.ts | 146 +++++ .../expoDriverNotificationService.ts | 195 ++++--- .../expo/storage/driverCommandStore.ts | 35 ++ src/ui/driver/DriverDeliveryException.tsx | 103 ++++ src/ui/driver/DriverOperationalInbox.tsx | 105 ++++ src/ui/driver/DriverWorkspace.tsx | 459 +++++++++++++--- 34 files changed, 4617 insertions(+), 290 deletions(-) create mode 100644 docs/driver-operational-notifications.md create mode 100644 docs/driver-operational-verification.md create mode 100644 src/api/dsvDriverOperational.test.ts create mode 100644 src/api/dsvDriverOperational.ts create mode 100644 src/app/driverNotificationAppRoot.test.ts create mode 100644 src/app/driverNotificationNavigation.test.ts create mode 100644 src/app/driverNotificationNavigation.ts create mode 100644 src/app/driverOperationalFlow.test.ts create mode 100644 src/app/driverWorkspaceReads.test.ts create mode 100644 src/config/driverOperational.ts create mode 100644 src/domain/delivery/driverCommandQueue.test.ts create mode 100644 src/domain/delivery/driverCommandQueue.ts create mode 100644 src/domain/notifications/driverNotificationRecovery.test.ts create mode 100644 src/domain/notifications/driverNotificationRecovery.ts create mode 100644 src/platform/expo/notifications/expoDriverNotificationService.test.ts create mode 100644 src/platform/expo/storage/driverCommandStore.ts create mode 100644 src/ui/driver/DriverDeliveryException.tsx create mode 100644 src/ui/driver/DriverOperationalInbox.tsx diff --git a/.env.example b/.env.example index f2202df..869516e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ # Public DSV production API. Override only for an explicitly approved local environment. EXPO_PUBLIC_DSV_API_BASE_URL=https://clever-route-api.cleversystem.ai + +# Additive PR483 APIs are unmerged/undeployed. Enable only for an approved isolated test server. +EXPO_PUBLIC_DSV_OPERATIONAL_ENABLED=false diff --git a/docs/driver-operational-notifications.md b/docs/driver-operational-notifications.md new file mode 100644 index 0000000..86b8626 --- /dev/null +++ b/docs/driver-operational-notifications.md @@ -0,0 +1,56 @@ +# Driver Android 운영 알림과 명령 + +## 기준과 사용 조건 + +- 통합 기준: Driver `dev` `4009a9f522372a9cf23272ba723bfbbb76d96253`. +- Target: [Driver #63](https://github.com/EVNSolution/clever-driver-app/issues/63). +- 앱 change-control: [#312](https://github.com/EVNSolution/clever-change-control/issues/312). +- 서버 [#310](https://github.com/EVNSolution/clever-change-control/issues/310)은 모바일 구현을 제외한다. #312가 앱 범위를 별도로 기록한다. +- 서버 계약: [PR483 af9b4b43](https://github.com/EVNSolution/clever-route-server/blob/af9b4b43b40a5c6c5cba7bc83587217b2bbe25ab/docs/contracts/dsv-operational-server-v1.md). +- 서버 route, DTO, provider와 명령 service를 위 SHA에서 대조했다. 서버 참조는 읽기 전용이다. +- 서버 PR483은 미병합·미배포다. 운영 서버 준비를 가정하지 않는다. +- 새 기능은 `EXPO_PUBLIC_DSV_OPERATIONAL_ENABLED=true`일 때만 사용한다. 기본값은 OFF다. +- 검증 시 `EXPO_PUBLIC_DSV_API_BASE_URL`을 승인된 격리 서버로 지정한다. 합성 응답 테스트는 운영 서버를 호출하지 않는다. +- 앱 플래그는 서버 실발송·지오펜싱을 활성화하지 않는다. 기존 `route_changed`, `bundle_handoff`는 유지한다. + +## 기존 후보와 의존 관계 + +PR61 `a7959e5a84393d7dc57e2caa654ea2f8edad202c`은 Draft·미병합이다. PR61의 변경은 이 dev 기준에 포함되지 않는다. 기존 `cc-240-completion-flow` 작업공간은 보존한다. + +겹치는 파일은 `src/app/AppRoot.tsx`, `src/ui/driver/DriverWorkspace.tsx`, `src/api/dsvDriverEvents.ts`, 관련 실행/알림 테스트, `docs/project-brief.md`, `docs/technology-stack.md`, `package.json`, `package-lock.json`이다. PR61의 완료 시간·증빙·ETA·가입 화면 변경을 이 작업에 가져오지 않는다. Expo 56의 필수 patch 정합성 및 기존 brace-expansion 및 shell-quote 보안 patch는 별도 검증하여 적용한다. 통합 시 PR61과 이 PR의 실행 명령·인증·조회 변경을 함께 리뷰해야 한다. + +[Issue62](https://github.com/EVNSolution/clever-driver-app/issues/62)의 보안 차단은 유지한다. CI audit을 삭제하거나 예외 처리하지 않는다. 소스/합성 검증과 병합·배포·실기기 후보 설치 승인은 구분한다. + +## 알림과 인증 복구 + +FCM data는 `schemaVersion: "1"`, `kind: N01..N06`, UUID `notificationId`, UTC `expiresAt`만 포함한다. 앱은 형식과 만료를 검증한다. 지원하지 않는 payload는 안내 화면으로 처리한다. 알림 제목·본문을 배송 권한의 근거로 사용하지 않는다. + +- 알림함은 계정 인증으로 서버 목록을 조회한다. `읽음`은 READ ack다. +- 알림 클릭은 인증 resolver를 호출한다. 현재 배정과 권한을 서버가 확인한다. +- N03은 배정 해제 사실만 표시한다. +- N06은 resolver의 `targetStopId`에 해당하는 배송지를 표시한다. 앱 로더는 `deliveryStopId`를 `DeliveryOrder.id`로 매핑한다. destinationId나 RoutePlanStop ID와 혼동하지 않는다. +- 취소·재배정·만료·권한 상실·완료 배송지는 상세 정보 대신 안내 화면을 표시한다. +- 클릭 ID는 로그인 전·종료 상태·재시작에서 복구한다. 미해결 클릭은 완료 처리하지 않는다. +- 화면이 목적지를 확인한 뒤 OPENED ack와 처리 완료를 저장한다. READ/OPENED는 업무 완료가 아니다. +- 로그아웃은 클릭과 native last response를 삭제한다. 계정 변경은 이전 목적지와 데이터, 진행 중 응답을 무효화한다. +- 인증 갱신은 access token을 메모리에서 교체한다. refresh token 보관은 기존 SecureStore 계약을 따른다. + +기존 `route-updates` Android 채널과 직접 FCM 토큰을 사용한다. 토큰 등록 응답의 `pushToken.id`를 capability의 `tokenId`로 전달한다. capability `installationId`는 토큰 등록 `deviceId`와 동일하다. 갱신된 토큰 등록 후 capability를 다시 등록한다. 권한 거부 시 토큰을 등록하지 않으며, 앱 활성화 시 권한 복구를 다시 확인한다. + +## 읽기와 업무 명령 + +목록·상세·화면 진입·알림 클릭은 ROUTE_STARTED, PICKUP_COMPLETED, ROUTE_COMPLETED를 전송하지 않는다. 기존 DriverWorkspace의 두 자동 완료 보정 경로를 제거했다. 완료 복구는 사용자의 `배차 완료 복구` 입력으로 실행한다. 서버에 승인된 명령만 성공으로 표시한다. + +새 executionContext 배차는 계정 토큰으로 `/api/dsv/driver/executions/:id/start`를 호출한다. 서버가 시작과 픽업을 원자적으로 승인한다. 응답 유실 시 기존 두 POST로 우회하지 않는다. 새 기능 OFF 상태의 기존 배차는 기존 시작 API를 유지한다. + +미배송 보고는 `/api/dsv/driver/executions/:id/delivery-exceptions`를 호출한다. 보고는 STOP_FAILED, 주문 FAILED 또는 배송 완료를 만들지 않는다. D05 운영 사유 목록은 미확정이다. UI는 `AppRoot`의 `deliveryExceptionReasons`로 주입한 목록만 사용하며 기본값은 비어 있다. 테스트의 합성 사유는 운영 정책이 아니다. + +소형 명령은 전송 전에 저장한다. 저장 데이터에는 명령 ID·발생 시각·계정 ID·executionContextId·routeVersion·assignmentEpoch·assignmentGeneration·expectedRouteVersionId와 보고 입력만 포함한다. access token, refresh token, 사진과 GPS는 저장하지 않는다. 재시도·재시작은 같은 commandId와 occurredAt을 유지한다. 전송 전 현재 context와 모든 fence를 다시 확인한다. 계정·배정·버전 충돌은 차단 상태로 표시하고 새 배차로 변환하지 않는다. 서버 ACK를 확인한 뒤 승인 상태를 저장한다. + +## 검증 경계 + +위치 판정, 출발 확인과 T+300초 N05 예약은 서버 책임이다. 앱에 geofence 또는 반복 로컬 알림 타이머가 없다. 반복 최대 6회는 운영 정책으로 사용하지 않는다. + +자동 검증은 합성 payload·API 응답·영속 저장과 실제 앱 함수/화면 조회 effect를 실행한다. 합성 클릭, 실제 FCM 수신, Android native 설치/화면 증거를 별도로 기록한다. 검증 결과와 남은 차단은 `driver-operational-verification.md`에 기록한다. + +범위 제외: 병합, 서버 배포, 실발송 활성화, Play·Drive 게시, 운영 DB 변경, iOS, 외부 HTTPS App Links, 전체 사진 오프라인 큐. diff --git a/docs/driver-operational-verification.md b/docs/driver-operational-verification.md new file mode 100644 index 0000000..8260c44 --- /dev/null +++ b/docs/driver-operational-verification.md @@ -0,0 +1,76 @@ +# Driver Android 운영 알림 검증 + +검증일: 2026-10-07, Asia/Seoul. +Target: Driver #63. 앱 change-control: #312. 기준 계약: 서버 PR483 `af9b4b43b40a5c6c5cba7bc83587217b2bbe25ab`. + +## 기준과 보존 + +- 원격 dev는 `4009a9f522372a9cf23272ba723bfbbb76d96253`으로 재확인했다. +- 초기 core는 clean dev였다. 구현은 dev 기반 GitHub Development linked branch `cc-312-driver-operational`과 별도 managed worktree에서 수행했다. +- PR61은 Draft·미병합, SHA `a7959e5a84393d7dc57e2caa654ea2f8edad202c`이다. 기존 worktree와 변경을 보존했다. +- 서버 #310은 모바일 구현을 제외한다. 앱 #312는 서버 계약을 참조하는 별도 승인 범위다. +- 최종 소스 SHA와 PR 링크, 해당 SHA의 빌드·CI 로그는 PR 완료 기록에 남긴다. + +## 검사 결과 + +| 검사 | 결과 | +|---|---| +| `npm run check:workspace` | PASS. TypeScript와 테스트 253/253. 실패·skip 0. | +| `npm run lint` | PASS. 기존 규칙을 유지했다. | +| `npx expo install --check` | PASS. Expo 56.0.23 / notifications 56.0.26 정합성. | +| `git diff --check` | PASS. | +| `npm run build:android -- --max-workers 1` | PASS. Android Hermes export. 최종 SHA의 로그·번들 hash는 PR에 기록한다. | +| `npm audit --audit-level=moderate` | BLOCKED. exit 1. braces / node-forge 두 advisory가 남는다. 의존 패키지 항목 high 20, critical 0. 독립 취약점 20개라는 의미가 아니다. | +| 독립 소스 리뷰 | APPROVE. 보고된 결함을 수정하고 관련 실행 테스트 90/90을 재검증했다. 미해결 코드 결함 0. | + +필수 Expo patch와 기존 override의 지원되는 brace-expansion 5.0.12 / shell-quote 1.11.0 patch를 적용했다. 강제 SDK downgrade, audit 예외와 CI 검사 삭제는 사용하지 않았다. Issue62는 계속 열린 보안 차단이다. + +## 합성 실행 증거 + +테스트는 실제 API 함수, payload parser, 영속 명령 큐, 클릭 복구, AppRoot/DriverWorkspace/Expo adapter의 생산 코드를 실행한다. 네이티브 컴포넌트와 네트워크는 합성 응답·VM adapter로 대체한다. + +| 흐름 또는 경계 | 실행 증거 | +|---|---| +| 등록 N01 → 창고 안내 N04 → 출발 확인 T+300초 N05 → 클릭 → 로그인 복구 → 배차 → 사용자 시작 → 승인 | `driverOperationalFlow.test.ts`의 연결 합성 시나리오. 인텐트 시간은 합성 서버가 만든다. 앱 예약 타이머가 아니다. | +| 조회·상세·새로고침·탭 진입 | DriverWorkspace effect 실행에서 업무 POST 0. 완료 배송지가 있어도 자동 ROUTE_COMPLETED 없음. | +| 알림 클릭·로그인 복구 | 실제 AppRoot 실행. 로그인 전에 목적지 조회 없음. resolver 성공 및 Workspace 화면 확인 후 OPENED. | +| N03 / N06 | N03 배정 해제 사실만 표시. N06은 `targetStopId == DeliveryOrder.id`인 정확한 배송지. nextStop으로 대체하지 않음. | +| 중복 클릭·cold start·재시작 | 미해결 클릭 보존. 승인된 화면만 dedup. 명시적 알림함 열기는 다시 resolver 호출 가능. | +| 만료·권한 상실·재배정·완료 배송지 | 안전 안내. 오래된 상세와 다른 배송지에 대한 업무 명령을 만들지 않음. | +| 최초 입력 시 오프라인 | 시작·보고를 먼저 저장. GET/POST 연결 실패 후에도 원본 ID·시각·fence 유지. | +| 승인 응답 유실·불완전 응답·이미 시작한 배차 | pending 유지. 재시작 후 동일 요청으로 서버 receipt 확인. 기존 두 시작 POST 우회 없음. | +| 재배정·버전 충돌 | executionContextId와 모든 신·구 fence를 다시 확인. 이전 명령을 새 배차에 적용하지 않음. | +| HTTP401·인증 갱신 | 같은 계정에서 원본 명령을 pending 유지하고 갱신 후 replay. 다른 계정 또는 늦은 응답은 무효. | +| 로그아웃·계정 전환 | 이전 resolver, refresh, OPENED, native cleanup, FCM revoke가 새 계정 상태를 변경하지 않음. | +| 영속 저장 실패 | 클릭·OPENED 처리 완료를 미리 기록하지 않음. 명령은 저장 성공 후에만 전송. | +| 토큰 갱신 | 직접 FCM 등록 → 서버 tokenId → 동일 deviceId capability 순서. 이전 계정 DELETE와 새 계정 PUT 직렬화. | +| 알림 권한 | 거부 안내와 설정 이동. 앱 활성화 때 복구 재확인. 지원하지 않는 payload는 안전 안내. | +| 미배송 보고 | 주입한 합성 사유만 사용. REPORT API만 호출. STOP_FAILED·주문 FAILED·배송 완료 이벤트 없음. | +| 기존 완료 복구 | 명시적 사용자 입력만 ROUTE_COMPLETED 전송. 응답 유실·재시작 후 원본 legacy event ID와 발생 시각 재사용. | + +앱 재시작·인증 갱신·새로고침은 **기존에 사용자가 저장한 pending 명령**을 재시도할 수 있다. 이것은 조회가 새 업무 명령을 만드는 경로와 구분한다. 새 명령이 없는 읽기 테스트에서 업무 POST는 0회다. + +## Android 산출물과 기기 경계 + +- 저장소 Android build runbook과 build-hygiene의 run 절차를 읽었다. +- Metro worker 1, command-scoped Node old-space 상한 3072MiB를 사용한다. 전체 프로세스 메모리 상한이라는 의미는 아니다. +- 정상 캐시를 삭제하지 않는다. Hermes export·로그·metadata hash와 재사용 node_modules를 보존한다. +- `build:android`는 Hermes export다. APK/AAB 서명·설치와 동일하지 않다. +- private `google-services.json`을 제공하지 않은 export에서는 config 경고가 남는다. 실제 Firebase 및 서명 native 후보 증거가 아니다. +- 연결된 Galaxy SM-N981N을 읽기 전용으로 확인했다. 설치된 `com.evnsolution.clever.driver`는 `0.1.15 (26)`이다. +- 설치본의 base.apk SHA256은 `0ed81f68648c523ea11ad4e8537126877b0f530879e7328b6c0c45fa5773189d`이다. 기기 앱·자료를 교체하거나 삭제하지 않았다. +- 이번 dev 후보의 `app.json`은 기존 `0.1.14 (23)`을 유지한다. PR61 또는 기기 설치본을 dev에 이미 있는 릴리스로 취급하지 않는다. + +| 증거 | 결과 | +|---|---| +| 합성 payload·클릭·인증·업무 명령 실행 | 검증됨. | +| 기기 연결·기존 패키지 버전·hash 읽기 | 검증됨. | +| 새 후보 native APK 설치·화면·권한·카메라 | 미검증. Issue62가 열려 있고 서명/Firebase native 후보를 만들거나 설치하지 않았다. | +| 실제 FCM 수신·배경/종료 상태 native 클릭 | 미검증. 합성 VM 결과와 구분한다. | +| 서버 runtime·운영 배차·실제 GPS·실발송 | 미검증. 서버 PR483은 미병합·미배포다. | + +## 남은 차단 + +Issue62 보안 검사, 서버 API 통합/배포 증거, D05 운영 사유 승인, 실제 FCM 및 native 기기 후보 검증이 남는다. 기능은 기본 OFF다. 앱 플래그는 서버 지오펜싱·실발송 승인이 아니다. 반복 6회 정책은 구현하지 않았다. + +PR 병합, 서버 배포, 실발송 활성화, Play·Drive 게시, 운영 DB 변경, iOS 작업은 수행하지 않았다. diff --git a/docs/project-brief.md b/docs/project-brief.md index 638269b..9ec37b4 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -146,3 +146,11 @@ localhost 또는 `127.0.0.1` 개발을 위한 - 배송 시작 이후 반납·확보 가능 여부 - 온도와 적재 조건에 따른 확보 자격 - 관리자 예외 처리와 감사 기록 + +## Android 운영 알림 후보 + +서버 PR483의 확정 기술 계약을 사용하는 알림함·알림 화면 이동·원자적 시작·미배송 보고 후보는 +기본 OFF로 유지한다. 서버 PR은 미병합·미배포다. `EXPO_PUBLIC_DSV_OPERATIONAL_ENABLED=true`와 +승인된 격리 서버를 지정한 검증 환경에서만 사용한다. 자세한 계약과 기존 PR61의 겹치는 범위는 +[운영 알림 문서](driver-operational-notifications.md)를 따른다. 운영 D05 사유 목록은 미확정이며 +주입된 합성 목록은 검증에만 사용한다. 목록·상세·알림 진입은 업무 상태를 변경하지 않는다. diff --git a/docs/technology-stack.md b/docs/technology-stack.md index 084ddd3..a03badb 100644 --- a/docs/technology-stack.md +++ b/docs/technology-stack.md @@ -77,3 +77,15 @@ access token과 계정 정보는 화면 메모리에만 두고, 30일 refresh to 1. 승인된 사용자 기능에 직접 필요하다. 2. Expo 56과의 버전 정합성이 확인된다. 3. 표준 라이브러리나 현재 의존성으로 해결할 수 없다. + +## Android 운영 알림과 소형 명령 + +기존 Expo Notifications의 직접 FCM 토큰과 `route-updates` 채널을 재사용한다. +토큰 등록의 서버 tokenId와 동일한 deviceId로 schema-v1 capability를 등록한다. +인증 resolver가 현재 목적지를 결정한다. 클릭 복구에는 알림 ID와 계정 바인딩만 저장한다. + +AsyncStorage는 소형 시작·보고 명령의 ID, 발생 시각과 신·구 fence를 저장한다. +재시도는 현재 계정과 서버 context를 다시 확인한다. 서버 승인 전에는 성공으로 표시하지 않는다. +access token과 목적지 상세는 영구 저장하지 않는다. 사진 오프라인 큐는 포함하지 않는다. +위치 판정과 5분 반복 알림 예약은 서버 책임이다. 새 네이티브 위치 기능이나 로컬 알림 타이머를 추가하지 않는다. +새 API 사용 조건은 [운영 알림 문서](driver-operational-notifications.md)를 따른다. diff --git a/package-lock.json b/package-lock.json index c951594..fecd8ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,13 @@ "dependencies": { "@maplibre/maplibre-react-native": "11.3.4", "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~56.0.21", + "expo": "~56.0.23", "expo-application": "~56.0.3", "expo-clipboard": "~56.0.4", "expo-file-system": "~56.0.11", "expo-font": "~56.0.7", "expo-image-picker": "~56.0.25", - "expo-notifications": "~56.0.25", + "expo-notifications": "~56.0.26", "expo-secure-store": "~56.0.4", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.7", @@ -1893,9 +1893,9 @@ "license": "MIT" }, "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz", + "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==", "license": "MIT", "dependencies": { "@expo/sudo-prompt": "^9.3.1", @@ -2169,19 +2169,19 @@ "license": "MIT" }, "node_modules/@expo/ws-tunnel": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", - "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", + "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", "license": "MIT", "peerDependencies": { "ws": "^8.0.0" } }, "node_modules/@expo/xcpretty": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", - "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", - "license": "BSD-3-Clause", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.6.tgz", + "integrity": "sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==", + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", @@ -3610,9 +3610,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.6.tgz", - "integrity": "sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", + "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -4073,9 +4073,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4385,14 +4385,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -4400,6 +4401,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -5421,13 +5426,13 @@ } }, "node_modules/expo": { - "version": "56.0.21", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.21.tgz", - "integrity": "sha512-EbN+wFq/bzREf2/4RKjt9UQoYCpB+ohxPDHVLMxkWlepvidpLZgezGberWp7CMzqLdpff+66jdL0fmO3aMRq1g==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.23.tgz", + "integrity": "sha512-SPPgww/V24xpe+vELAIKOrkwKF8g8o6TLfaIMqdO56PtTvCUvJQPfJfnj/bCsQJFIjoQVHOZZ03B6Zx/VYAPsQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.25", + "@expo/cli": "^56.1.26", "@expo/config": "~56.0.14", "@expo/config-plugins": "~56.0.16", "@expo/devtools": "~56.0.2", @@ -5439,13 +5444,13 @@ "@expo/metro-config": "~56.0.19", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.20", - "expo-asset": "~56.0.24", - "expo-constants": "~56.0.25", + "expo-asset": "~56.0.25", + "expo-constants": "~56.0.27", "expo-file-system": "~56.0.11", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.23", - "expo-modules-core": "~56.0.25", + "expo-modules-core": "~56.0.27", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -5503,9 +5508,9 @@ } }, "node_modules/expo-constants": { - "version": "56.0.25", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.25.tgz", - "integrity": "sha512-KlYOe6F/7lxyedsSrZJHhOjff7goDeJ+tp+e1rK0/ohmt+AxziB3vfyvwcqamC5lRhxJYAgz2CCLAU3Rbb7xUg==", + "version": "56.0.27", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.27.tgz", + "integrity": "sha512-uSCNjve0uHm81BqCn8uRfliWd6FaNMbZBNM1EH7HNWSXVf5T3J3W5laL2DHPR7m+L91JXHxqux5+qsL+C3iWrA==", "license": "MIT", "dependencies": { "@expo/env": "~2.3.1" @@ -5590,13 +5595,13 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.25", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.25.tgz", - "integrity": "sha512-bNqzl82QrxK7BEPT2H4zrel58GG1X6aHYdJXSkn/2riaKiNgstHmtyTI9oO4G6qCB3DYXZQ1572aCF14ZxCnFw==", + "version": "56.0.27", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.27.tgz", + "integrity": "sha512-Y/KuCJGsVmQfht9F7aorWoROaPtVp6DFT/5H3wwmhZKM22D7SYGiSV9mtXiV2LfFP5N9jBh/0iwtXFAaYajsIQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", - "expo-modules-jsi": "~56.0.13", + "expo-modules-jsi": "~56.0.14", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5611,25 +5616,25 @@ } }, "node_modules/expo-modules-jsi": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.13.tgz", - "integrity": "sha512-If9W5Me4aSaBSgLD6MPUm6YDVB0KNsK2gIEaTeLEO9dnTqmRcJ3WM206S8iix+4GSvXMOyE8mLlmqE1cQMUuyQ==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.14.tgz", + "integrity": "sha512-Hzjq8plGfjGb7OsensIBYkzQsVLVotws2arFVZJb6LV6vE9L8B3E1xUlejzlAsUR5eRKE3aImK+P+4X2slGVGQ==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-notifications": { - "version": "56.0.25", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.25.tgz", - "integrity": "sha512-PXp4YFbBMnj4JwGRmizVOB2MfKHwPbK4ZeusCPpjzk+6dgXuNM1KbTVQK3naVZT4WUsm3l9VLXym2fcMFxH9Cg==", + "version": "56.0.26", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.26.tgz", + "integrity": "sha512-YzQmpNGUg20BWJvHGmHm1ARxdf/6EZhe3fM1y9UwMIIcMTwrUFN8xOWAP70V8N+vPgI2AlPbozQzExBWDmdK6w==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.5", "abort-controller": "^3.0.0", "badgin": "^1.1.5", "expo-application": "~56.0.3", - "expo-constants": "~56.0.25" + "expo-constants": "~56.0.27" }, "peerDependencies": { "expo": "*", @@ -5683,9 +5688,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "56.1.25", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.25.tgz", - "integrity": "sha512-5tBKSRoGWwGfQfA2IupoUf2WMcRYGAO1mCvjTIJ1u+ff3+f4uAJQxB5xdD6VjU8G3KHdLSDhsc7UL4uKIjwtSA==", + "version": "56.1.26", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.26.tgz", + "integrity": "sha512-l0++HkmC4ka2/RwB2Kdti2G6MNM1safxfip7i/Nuuw1sjeFqsjZ8OMjdKV127Lgznjaxep7Y/ElYlC6jxs+Faw==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -5705,14 +5710,14 @@ "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.23", "@expo/require-utils": "^56.1.7", - "@expo/router-server": "^56.0.18", + "@expo/router-server": "^56.0.19", "@expo/schema-utils": "^56.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", - "agent-cli-detector": "0.1.6", + "agent-cli-detector": "0.1.7", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", @@ -5766,17 +5771,17 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.18.tgz", - "integrity": "sha512-3Ko9M9yIu9pcWcNik55JT3DYrODumIFkrRN6xbL+HtUFgiQp6N02gt6LngPRu0r7YKWYvHU12rsM3MDtucs63Q==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.19.tgz", + "integrity": "sha512-0R6i+WzDTOe0HVgeYAQI3y8apnypKIAqlitDlaAF3YX8Am3XUakZLpLKk9TQUeM/VcTl3f9cnM/WvgczXvle6g==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^56.0.20", + "@expo/metro-runtime": "^56.0.21", "expo": "*", - "expo-constants": "^56.0.24", + "expo-constants": "^56.0.26", "expo-font": "^56.0.7", "expo-router": "*", "expo-server": "^56.0.6", @@ -5976,13 +5981,13 @@ } }, "node_modules/expo/node_modules/expo-asset": { - "version": "56.0.24", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.24.tgz", - "integrity": "sha512-Zy41rl0gtfyZmzV1bzxZype1iEAKz/FiTKOw3yTvA5I2biGnDSh0GbTAYQzkrqYbhnyJAqIe+sXe2eo4DfJ8KQ==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.25.tgz", + "integrity": "sha512-D8Gzrj/c/FwgRyx+WLNZdKNTXJLwkNDV9meEabNzWwZFI7mxv8NRYhyzZuJSu4ndaviausTnSGEzAHGrIYicnA==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.10.5", - "expo-constants": "~56.0.25" + "expo-constants": "~56.0.27" }, "peerDependencies": { "expo": "*", @@ -8395,9 +8400,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -9840,9 +9845,9 @@ } }, "node_modules/shell-quote": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.11.0.tgz", + "integrity": "sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9969,9 +9974,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 9e2ab51..978929b 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,13 @@ "dependencies": { "@maplibre/maplibre-react-native": "11.3.4", "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~56.0.21", + "expo": "~56.0.23", "expo-application": "~56.0.3", "expo-clipboard": "~56.0.4", "expo-file-system": "~56.0.11", "expo-font": "~56.0.7", "expo-image-picker": "~56.0.25", - "expo-notifications": "~56.0.25", + "expo-notifications": "~56.0.26", "expo-secure-store": "~56.0.4", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.7", @@ -55,16 +55,16 @@ "node": ">=20.19.4" }, "overrides": { - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "js-yaml": "4.3.2", "postcss": "8.5.23", "minimatch": "10.2.6", "minimatch@10.2.6": { - "brace-expansion": "5.0.9" + "brace-expansion": "5.0.12" }, "uuid": "11.1.1", "ws": "8.21.0", - "shell-quote": "1.10.0" + "shell-quote": "1.11.0" }, "expo": { "autolinking": { diff --git a/src/api/dsvDriverEvents.test.ts b/src/api/dsvDriverEvents.test.ts index 142af99..18ce7a2 100644 --- a/src/api/dsvDriverEvents.test.ts +++ b/src/api/dsvDriverEvents.test.ts @@ -97,6 +97,21 @@ describe('DSV driver events API client', () => { assert.match(body.clientEventId as string, /^route-1:completed:/u); }); + it('replays the explicit completion identity unchanged after response loss', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + const bodies: unknown[] = []; + globalThis.fetch = async (_input, init) => { + bodies.push(JSON.parse(init?.body as string)); + if (bodies.length === 1) throw new Error('response lost'); + return new Response(JSON.stringify({ data: { eventId: 'event-1' }, error: null })); + }; + const identity = { clientEventId: 'route-1:completed:stable-command', occurredAt: '2026-10-07T01:00:00.000Z' }; + await assert.rejects(completeDriverDeliveryRoute('route-token', 'route-1', identity), /response lost/u); + await completeDriverDeliveryRoute('route-token', 'route-1', identity); + assert.deepEqual(bodies[0], bodies[1]); + assert.deepEqual(bodies[1], { ...identity, eventType: 'ROUTE_COMPLETED', routePlanId: 'route-1' }); + }); + it('acknowledges a stop time change through the existing event endpoint', async () => { process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; let request: { input: string; init?: RequestInit } | undefined; diff --git a/src/api/dsvDriverEvents.ts b/src/api/dsvDriverEvents.ts index 19cc687..8c020eb 100644 --- a/src/api/dsvDriverEvents.ts +++ b/src/api/dsvDriverEvents.ts @@ -11,11 +11,17 @@ type RouteLifecycleEventType = | 'ROUTE_STARTED' | 'TIME_CONSTRAINT_ACKNOWLEDGED'; +export type DriverLifecycleCommandIdentity = { + clientEventId: string; + occurredAt: string; +}; + async function recordRouteLifecycleEvent( accessToken: string, routePlanId: string, eventType: RouteLifecycleEventType, deliveryStopId?: string, + identity?: DriverLifecycleCommandIdentity, ): Promise { const eventName = eventType === 'ROUTE_STARTED' ? 'started' @@ -26,10 +32,10 @@ async function recordRouteLifecycleEvent( : `time:${deliveryStopId}`; const response = await fetch(resolveDsvApiUrl('/driver/events'), { body: JSON.stringify({ - clientEventId: `${routePlanId}:${eventName}:${Date.now()}`, + clientEventId: identity?.clientEventId ?? `${routePlanId}:${eventName}:${Date.now()}`, ...(deliveryStopId === undefined ? {} : { deliveryStopId }), eventType, - occurredAt: new Date().toISOString(), + occurredAt: identity?.occurredAt ?? new Date().toISOString(), routePlanId, }), headers: { @@ -94,8 +100,9 @@ export async function startDriverDeliveryRoute( export async function completeDriverDeliveryRoute( accessToken: string, routePlanId: string, + identity?: DriverLifecycleCommandIdentity, ): Promise { - await recordRouteLifecycleEvent(accessToken, routePlanId, 'ROUTE_COMPLETED'); + await recordRouteLifecycleEvent(accessToken, routePlanId, 'ROUTE_COMPLETED', undefined, identity); } export async function completeDriverDeliveryDestination( diff --git a/src/api/dsvDriverOperational.test.ts b/src/api/dsvDriverOperational.test.ts new file mode 100644 index 0000000..04e93ec --- /dev/null +++ b/src/api/dsvDriverOperational.test.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import { + acknowledgeDriverOperationalNotification, + DriverOperationalApiError, + loadDriverExecutionContexts, + loadDriverOperationalInbox, + registerDriverOperationalCapability, + reportDriverDeliveryException, + resolveDriverOperationalNotification, + startDriverExecution, +} from './dsvDriverOperational'; + +const originalFetch = globalThis.fetch; +const originalBase = process.env.EXPO_PUBLIC_DSV_API_BASE_URL; +const id = '11111111-1111-4111-8111-111111111111'; +const routeId = '22222222-2222-4222-8222-222222222222'; +const childId = '33333333-3333-4333-8333-333333333333'; +const commandId = '44444444-4444-4444-8444-444444444444'; +const stopId = '55555555-5555-4555-8555-555555555555'; +const instant = '2026-10-07T01:02:03.000Z'; +const command = { + assignmentEpoch: '9223372036854775807', assignmentGeneration: '7', commandId, + expectedRouteVersionId: childId, occurredAt: instant, routeVersion: 3, +}; +const execution = { + executionContextId: id, routePlanId: routeId, routeVersion: 3, + assignmentEpoch: command.assignmentEpoch, assignmentGeneration: '7', + expectedRouteVersionId: childId, serviceDate: '2026-10-07', status: 'ACTIVE', startedAt: null, +}; +const inboxItem = { + ackedAt: null, businessStatus: 'OPEN', createdAt: instant, + expiresAt: '2026-10-07T02:02:03.000Z', id, kind: 'N05', + summary: { title: '운행 시작 확인이 필요합니다', body: '안전한 곳에서 시작을 확인해 주세요.' }, +}; +const startResult = { + assignmentEpoch: command.assignmentEpoch, commandId, duplicate: false, + executionContextId: id, pickupCompletedEventId: childId, + routeStartedEventId: routeId, routeVersion: 3, +}; +const exceptionResult = { + assignmentEpoch: command.assignmentEpoch, commandId, duplicate: false, + exceptionId: childId, executionContextId: id, notificationId: routeId, routeVersion: 3, +}; +const response = (data: unknown, status = 200) => + new Response(JSON.stringify({ data, error: null }), { status }); +const invalidResponse = (error: unknown) => + error instanceof DriverOperationalApiError && error.code === 'INVALID_RESPONSE'; + +describe('Driver operational API at server af9b4b43', () => { + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalBase === undefined) delete process.env.EXPO_PUBLIC_DSV_API_BASE_URL; + else process.env.EXPO_PUBLIC_DSV_API_BASE_URL = originalBase; + }); + + it('reads contexts, inbox and resolver with zero business POST requests', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + const methods: string[] = []; + globalThis.fetch = async (url, init) => { + methods.push(init!.method!); + assert.equal(new Headers(init!.headers).get('Authorization'), 'Bearer account-token'); + if (String(url).endsWith('/executions')) return response({ items: [execution], limit: 100 }); + if (String(url).endsWith('/resolve')) { + return response({ notificationId: id, destination: { + type: 'EXECUTION', executionContextId: id, routePlanId: routeId, targetStopId: stopId, + } }); + } + assert.match(String(url), /operational-notifications\?limit=30&cursor=cursor%2B%2F%3F%3D$/u); + return response({ items: [inboxItem], nextCursor: 'cursor+/?=' }); + }; + assert.deepEqual(await loadDriverExecutionContexts('account-token'), [execution]); + assert.deepEqual(await loadDriverOperationalInbox('account-token', 'cursor+/?='), { + items: [inboxItem], nextCursor: 'cursor+/?=', + }); + assert.deepEqual(await resolveDriverOperationalNotification('account-token', id), { + notificationId: id, destination: { + type: 'EXECUTION', executionContextId: id, routePlanId: routeId, targetStopId: stopId, + }, + }); + assert.deepEqual(methods, ['GET', 'GET', 'GET']); + }); + + it('posts one atomic start with both fence sets and caller-owned command identity', async () => { + const bodies: unknown[] = []; + globalThis.fetch = async (url, init) => { + assert.ok(String(url).endsWith(`/executions/${id}/start`)); + assert.equal(init!.method, 'POST'); + bodies.push(JSON.parse(init!.body as string)); + return response({ ...startResult, duplicate: bodies.length > 1 }, bodies.length > 1 ? 200 : 201); + }; + assert.deepEqual(await startDriverExecution('token', id, command), startResult); + assert.deepEqual(await startDriverExecution('token', id, command), { ...startResult, duplicate: true }); + assert.deepEqual(bodies, [command, command]); + }); + + it('keeps a lost start response retryable without legacy event fallback', async () => { + const calls: { url: string; body: unknown }[] = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), body: JSON.parse(init!.body as string) }); + if (calls.length === 1) throw new Error('Response lost after server commit'); + return response({ ...startResult, duplicate: true }); + }; + await assert.rejects(() => startDriverExecution('token', id, command), (error) => + error instanceof DriverOperationalApiError && error.status === 0 && error.code === 'NETWORK_ERROR'); + assert.equal((await startDriverExecution('token', id, command)).duplicate, true); + assert.equal(calls.length, 2); + assert.ok(calls.every((call) => call.url.endsWith(`/executions/${id}/start`))); + assert.deepEqual(calls.map((call) => call.body), [command, command]); + }); + + it('reports an injected synthetic reason without any delivery result event', async () => { + let calls = 0; + const report = { ...command, targetStopId: stopId, reasonCode: 'SYNTHETIC_REASON', explanation: '확인 필요' }; + globalThis.fetch = async (url, init) => { + calls += 1; + assert.ok(String(url).endsWith(`/executions/${id}/delivery-exceptions`)); + assert.deepEqual(JSON.parse(init!.body as string), report); + return response(exceptionResult, 201); + }; + assert.deepEqual(await reportDriverDeliveryException('token', id, report), exceptionResult); + assert.equal(calls, 1); + }); + + it('accepts only the limited N03 release destination', async () => { + globalThis.fetch = async () => response({ notificationId: id, destination: { type: 'ASSIGNMENT_RELEASED' } }); + assert.deepEqual(await resolveDriverOperationalNotification('token', id), { + notificationId: id, destination: { type: 'ASSIGNMENT_RELEASED' }, + }); + for (const destination of [ + { type: 'ASSIGNMENT_RELEASED', routePlanId: routeId }, + { type: 'OPERATIONS_EXCEPTION', executionContextId: id, reportId: routeId, targetStopId: stopId }, + { type: 'EXECUTION', executionContextId: id, routePlanId: routeId, targetStopId: null }, + ]) { + globalThis.fetch = async () => response({ notificationId: id, destination }); + await assert.rejects(() => resolveDriverOperationalNotification('token', id), invalidResponse); + } + }); + + it('sends READ and OPENED acknowledgements as separate non-business requests', async () => { + const kinds: string[] = []; + globalThis.fetch = async (url, init) => { + assert.ok(String(url).endsWith(`/operational-notifications/${id}/acks`)); + kinds.push(JSON.parse(init!.body as string).ackKind); + return response({ ackedAt: instant, notificationId: id }); + }; + for (const kind of ['READ', 'OPENED'] as const) { + assert.deepEqual(await acknowledgeDriverOperationalNotification('token', id, kind), { + ackedAt: instant, notificationId: id, + }); + } + assert.deepEqual(kinds, ['READ', 'OPENED']); + }); + + it('binds capability to the supplied installation and server token ID', async () => { + const capability = { installationId: 'installation-1', tokenId: routeId, schemaVersion: 1 as const, kinds: ['N01', 'N06'] as const }; + globalThis.fetch = async (url, init) => { + assert.ok(String(url).endsWith('/operational-notifications/capability')); + assert.deepEqual(JSON.parse(init!.body as string), capability); + return response({ capabilityId: childId, kinds: capability.kinds, schemaVersion: 1 }); + }; + assert.deepEqual(await registerDriverOperationalCapability('token', capability), { + capabilityId: childId, kinds: ['N01', 'N06'], schemaVersion: 1, + }); + }); + + it('keeps HTTP status and server code but does not expose private server messages', async () => { + for (const [status, code] of [[401, 'UNAUTHORIZED'], [403, 'FORBIDDEN'], [404, 'NOT_FOUND'], [409, 'ASSIGNMENT_CHANGED'], [503, 'DEPENDENCY_UNAVAILABLE']] as const) { + globalThis.fetch = async () => new Response(JSON.stringify({ data: null, error: { code, message: 'private detail' } }), { status }); + await assert.rejects(() => startDriverExecution('token', id, command), (error) => + error instanceof DriverOperationalApiError && error.status === status && error.code === code + && !error.message.includes('private detail')); + } + globalThis.fetch = async () => new Response('unavailable', { status: 503 }); + await assert.rejects(() => loadDriverExecutionContexts('token'), (error) => + error instanceof DriverOperationalApiError && error.status === 503 && error.code === 'HTTP_503'); + }); + + it('rejects stale or mismatched command acknowledgements', async () => { + for (const result of [ + { ...startResult, commandId: id }, { ...startResult, executionContextId: routeId }, + { ...startResult, assignmentEpoch: '8' }, { ...startResult, routeVersion: 4 }, + { ...startResult, duplicate: 'true' }, { ...startResult, routeStartedEventId: '' }, + ]) { + globalThis.fetch = async () => response(result); + await assert.rejects(() => startDriverExecution('token', id, command), invalidResponse); + } + globalThis.fetch = async () => response({ ...exceptionResult, exceptionId: 'not-a-uuid' }); + await assert.rejects(() => reportDriverDeliveryException('token', id, { ...command, targetStopId: stopId, reasonCode: 'SYNTHETIC_REASON' }), invalidResponse); + }); + + it('rejects malformed context dates, UUIDs and numeric fence substitutions', async () => { + for (const item of [ + { ...execution, assignmentEpoch: 7 }, { ...execution, assignmentGeneration: '0' }, + { ...execution, assignmentEpoch: '9223372036854775808' }, + { ...execution, expectedRouteVersionId: null }, { ...execution, routePlanId: 'route-1' }, + { ...execution, serviceDate: '2026-02-30' }, { ...execution, startedAt: 'yesterday' }, + { ...execution, routeVersion: 1.2 }, { ...execution, status: 'UNRECOGNIZED' }, + { ...execution, status: ['ACTIVE'] }, + ]) { + globalThis.fetch = async () => response({ items: [item], limit: 100 }); + await assert.rejects(() => loadDriverExecutionContexts('token'), invalidResponse); + } + }); + + it('rejects malformed inbox payloads and resolver identity mismatches', async () => { + for (const item of [ + { ...inboxItem, kind: 'N07' }, { ...inboxItem, businessStatus: 'FAILED' }, + { ...inboxItem, businessStatus: ['OPEN'] }, + { ...inboxItem, expiresAt: '2026-02-30T02:02:03.000Z' }, + { ...inboxItem, ackedAt: 1 }, { ...inboxItem, summary: { title: 'title' } }, + ]) { + globalThis.fetch = async () => response({ items: [item], nextCursor: null }); + await assert.rejects(() => loadDriverOperationalInbox('token'), invalidResponse); + } + globalThis.fetch = async () => response({ notificationId: routeId, destination: { type: 'ASSIGNMENT_RELEASED' } }); + await assert.rejects(() => resolveDriverOperationalNotification('token', id), invalidResponse); + globalThis.fetch = async () => response({ ackedAt: instant, notificationId: routeId }); + await assert.rejects(() => acknowledgeDriverOperationalNotification('token', id, 'READ'), invalidResponse); + globalThis.fetch = async () => response({ capabilityId: childId, kinds: ['N01', 'N06'], schemaVersion: 2 }); + await assert.rejects(() => registerDriverOperationalCapability('token', { + installationId: 'installation-1', tokenId: routeId, schemaVersion: 1, kinds: ['N01'], + }), invalidResponse); + }); + + it('rejects invalid commands before fetch and never invents fences', async () => { + globalThis.fetch = async () => { assert.fail('Invalid request must not fetch'); }; + const badRequest = (error: unknown) => error instanceof DriverOperationalApiError && error.status === 400 && error.code === 'BAD_REQUEST'; + for (const patch of [ + { commandId: 'not-a-uuid' }, { assignmentEpoch: '0' }, { assignmentGeneration: '01' }, + { routeVersion: 0 }, { expectedRouteVersionId: '' }, { occurredAt: '2026-02-30T01:02:03.000Z' }, + ]) { + await assert.rejects(() => startDriverExecution('token', id, { ...command, ...patch }), badRequest); + } + await assert.rejects(() => reportDriverDeliveryException('token', id, { + ...command, targetStopId: stopId, reasonCode: ' ', + }), badRequest); + await assert.rejects(() => reportDriverDeliveryException('token', id, { + ...command, targetStopId: stopId, reasonCode: 'SYNTHETIC_REASON', explanation: 'a'.repeat(1001), + }), badRequest); + await assert.rejects(() => registerDriverOperationalCapability('token', { + installationId: '', tokenId: routeId, schemaVersion: 1, kinds: ['N01'], + }), badRequest); + await assert.rejects(() => registerDriverOperationalCapability('token', { + installationId: 'installation-1', tokenId: routeId, schemaVersion: 1, kinds: ['N01', 'N01'], + }), badRequest); + await assert.rejects(() => loadDriverOperationalInbox('token', ''), badRequest); + }); + + it('times out without creating another command or endpoint call', async (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + globalThis.fetch = async (_url, init) => { + calls += 1; + assert.deepEqual(JSON.parse(init!.body as string), command); + return new Promise((_resolve, reject) => { + init!.signal!.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }); + }; + const pending = startDriverExecution('token', id, command); + context.mock.timers.tick(20_000); + await assert.rejects(() => pending, (error) => + error instanceof DriverOperationalApiError && error.status === 0 && error.code === 'REQUEST_TIMEOUT'); + assert.equal(calls, 1); + }); +}); diff --git a/src/api/dsvDriverOperational.ts b/src/api/dsvDriverOperational.ts new file mode 100644 index 0000000..c34110a --- /dev/null +++ b/src/api/dsvDriverOperational.ts @@ -0,0 +1,338 @@ +import { resolveDsvApiUrl } from './dsvApiUrl'; + +export type DriverOperationalNotificationKind = 'N01' | 'N02' | 'N03' | 'N04' | 'N05' | 'N06'; + +export type DriverExecutionContext = { + executionContextId: string; + routePlanId: string; + routeVersion: number; + assignmentEpoch: string; + assignmentGeneration: string; + expectedRouteVersionId: string; + serviceDate: string; + status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED'; + startedAt: string | null; +}; + +export type DriverOperationalCommandInput = { + commandId: string; + occurredAt: string; + routeVersion: number; + assignmentEpoch: string; + assignmentGeneration: string; + expectedRouteVersionId: string; +}; + +export type DriverDeliveryExceptionInput = DriverOperationalCommandInput & { + targetStopId: string; + reasonCode: string; + explanation?: string | null; +}; + +export type DriverStartExecutionResult = { + assignmentEpoch: string; + commandId: string; + duplicate: boolean; + executionContextId: string; + pickupCompletedEventId: string; + routeStartedEventId: string; + routeVersion: number; +}; + +export type DriverDeliveryExceptionResult = { + assignmentEpoch: string; + commandId: string; + duplicate: boolean; + exceptionId: string; + executionContextId: string; + notificationId: string; + routeVersion: number; +}; + +export type DriverOperationalInboxItem = { + ackedAt: string | null; + businessStatus: 'CANCELLED' | 'EXPIRED' | 'OPEN' | 'RESOLVED'; + createdAt: string; + expiresAt: string; + id: string; + kind: DriverOperationalNotificationKind; + summary: { body: string; title: string }; +}; + +export type DriverOperationalInboxPage = { + items: DriverOperationalInboxItem[]; + nextCursor: string | null; +}; + +export type DriverNotificationResolution = { + notificationId: string; + destination: + | { type: 'EXECUTION'; executionContextId: string; routePlanId: string; targetStopId?: string } + | { type: 'ASSIGNMENT_RELEASED' }; +}; + +export type DriverOperationalNotificationAck = { ackedAt: string; notificationId: string }; +export type DriverOperationalCapabilityInput = { + installationId: string; + tokenId: string; + schemaVersion: 1; + kinds: readonly DriverOperationalNotificationKind[]; +}; +export type DriverOperationalCapability = { + capabilityId: string; + kinds: DriverOperationalNotificationKind[]; + schemaVersion: 1; +}; + +export class DriverOperationalApiError extends Error { + constructor(readonly status: number, readonly code: string) { + super(code); + this.name = 'DriverOperationalApiError'; + } +} + +const root = '/api/dsv/driver'; +const notifications = `${root}/operational-notifications`; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const bigintPattern = /^[1-9]\d{0,18}$/u; +const instantPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; +const commandKeys = [ + 'assignmentEpoch', 'assignmentGeneration', 'commandId', 'expectedRouteVersionId', 'occurredAt', 'routeVersion', +] as const; +const notificationKinds: readonly DriverOperationalNotificationKind[] = ['N01', 'N02', 'N03', 'N04', 'N05', 'N06']; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: unknown, keys: readonly string[]): value is Record { + return isObject(value) && Object.keys(value).every((key) => keys.includes(key)); +} + +function isUuid(value: unknown): value is string { + return typeof value === 'string' && uuidPattern.test(value); +} + +function isPositiveBigint(value: unknown): value is string { + return typeof value === 'string' && bigintPattern.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n; +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +function isInstant(value: unknown): value is string { + if (typeof value !== 'string' || !instantPattern.test(value)) return false; + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) + && parsed.toISOString() === (value.includes('.') ? value : value.replace('Z', '.000Z')); +} + +function isServiceDate(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value) || value.startsWith('0000-')) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function isNullableInstant(value: unknown): value is string | null { + return value === null || isInstant(value); +} + +function isBoundedText(value: unknown, max: number): value is string { + return typeof value === 'string' && value.trim() !== '' && value.trim().length <= max + && ![...value].some((character) => { + const code = character.codePointAt(0)!; + return code < 32 || code === 127; + }); +} + +function isNotificationKind(value: unknown): value is DriverOperationalNotificationKind { + return typeof value === 'string' && notificationKinds.some((kind) => kind === value); +} + +function isKindList(value: unknown): value is DriverOperationalNotificationKind[] { + return Array.isArray(value) && value.length > 0 && value.length <= notificationKinds.length + && value.every(isNotificationKind) && new Set(value).size === value.length; +} + +function isExecution(value: unknown): value is DriverExecutionContext { + return hasOnlyKeys(value, [ + 'executionContextId', 'routePlanId', 'routeVersion', 'assignmentEpoch', 'assignmentGeneration', + 'expectedRouteVersionId', 'serviceDate', 'status', 'startedAt', + ]) && isUuid(value.executionContextId) && isUuid(value.routePlanId) + && isPositiveInteger(value.routeVersion) && isPositiveBigint(value.assignmentEpoch) + && isPositiveBigint(value.assignmentGeneration) && isUuid(value.expectedRouteVersionId) + && isServiceDate(value.serviceDate) && typeof value.status === 'string' + && ['ACTIVE', 'CANCELLED', 'COMPLETED'].includes(value.status) + && isNullableInstant(value.startedAt); +} + +function isCommand(value: unknown): value is DriverOperationalCommandInput { + return isObject(value) && isUuid(value.commandId) && isInstant(value.occurredAt) + && isPositiveInteger(value.routeVersion) && isPositiveBigint(value.assignmentEpoch) + && isPositiveBigint(value.assignmentGeneration) && isUuid(value.expectedRouteVersionId); +} + +function isCommandResult(value: unknown): value is Record & { + assignmentEpoch: string; commandId: string; duplicate: boolean; executionContextId: string; routeVersion: number; +} { + return isObject(value) && isPositiveBigint(value.assignmentEpoch) && isUuid(value.commandId) + && typeof value.duplicate === 'boolean' && isUuid(value.executionContextId) && isPositiveInteger(value.routeVersion); +} + +function matchesCommand( + value: { assignmentEpoch: string; commandId: string; executionContextId: string; routeVersion: number }, + executionContextId: string, + input: DriverOperationalCommandInput, +): boolean { + return value.assignmentEpoch === input.assignmentEpoch && value.commandId === input.commandId + && value.executionContextId === executionContextId && value.routeVersion === input.routeVersion; +} + +function isStartResult(value: unknown): value is DriverStartExecutionResult { + return hasOnlyKeys(value, [ + 'assignmentEpoch', 'commandId', 'duplicate', 'executionContextId', + 'pickupCompletedEventId', 'routeStartedEventId', 'routeVersion', + ]) && isCommandResult(value) && isUuid(value.pickupCompletedEventId) && isUuid(value.routeStartedEventId); +} + +function isExceptionResult(value: unknown): value is DriverDeliveryExceptionResult { + return hasOnlyKeys(value, [ + 'assignmentEpoch', 'commandId', 'duplicate', 'exceptionId', 'executionContextId', 'notificationId', 'routeVersion', + ]) && isCommandResult(value) && isUuid(value.exceptionId) && isUuid(value.notificationId); +} + +function isInboxItem(value: unknown): value is DriverOperationalInboxItem { + return hasOnlyKeys(value, ['ackedAt', 'businessStatus', 'createdAt', 'expiresAt', 'id', 'kind', 'summary']) + && isNullableInstant(value.ackedAt) && typeof value.businessStatus === 'string' + && ['CANCELLED', 'EXPIRED', 'OPEN', 'RESOLVED'].includes(value.businessStatus) + && isInstant(value.createdAt) && isInstant(value.expiresAt) && isUuid(value.id) && isNotificationKind(value.kind) + && hasOnlyKeys(value.summary, ['body', 'title']) && typeof value.summary.body === 'string' + && typeof value.summary.title === 'string'; +} + +function isResolution(value: unknown): value is DriverNotificationResolution { + if (!hasOnlyKeys(value, ['destination', 'notificationId']) || !isUuid(value.notificationId)) return false; + const destination = value.destination; + if (!isObject(destination)) return false; + if (destination.type === 'ASSIGNMENT_RELEASED') return hasOnlyKeys(destination, ['type']); + return hasOnlyKeys(destination, ['type', 'executionContextId', 'routePlanId', 'targetStopId']) + && destination.type === 'EXECUTION' && isUuid(destination.executionContextId) && isUuid(destination.routePlanId) + && (!Object.hasOwn(destination, 'targetStopId') || isUuid(destination.targetStopId)); +} + +function badRequest(): never { + throw new DriverOperationalApiError(400, 'BAD_REQUEST'); +} + +// A transport retry must reuse the original command and fences. This client never +// retries a business command or substitutes the legacy /driver/events endpoints. +async function request( + path: string, + accessToken: string, + init: RequestInit, + validate: (value: unknown) => value is T, +): Promise { + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20_000); + try { + const headers = new Headers(init.headers); + headers.set('Accept', 'application/json'); + headers.set('Authorization', `Bearer ${accessToken}`); + const response = await fetch(resolveDsvApiUrl(path), { ...init, headers, signal: controller.signal }); + const envelope: unknown = await response.json().catch(() => null); + if (!response.ok) { + const code = isObject(envelope) && isObject(envelope.error) && typeof envelope.error.code === 'string' + ? envelope.error.code : `HTTP_${response.status}`; + throw new DriverOperationalApiError(response.status, code); + } + if (!hasOnlyKeys(envelope, ['data', 'error']) || envelope.error !== null || !validate(envelope.data)) { + throw new DriverOperationalApiError(response.status, 'INVALID_RESPONSE'); + } + return envelope.data; + } catch (error) { + if (timedOut) throw new DriverOperationalApiError(0, 'REQUEST_TIMEOUT'); + if (error instanceof DriverOperationalApiError) throw error; + throw new DriverOperationalApiError(0, 'NETWORK_ERROR'); + } finally { + clearTimeout(timeout); + } +} + +function post(body: unknown): RequestInit { + return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; +} + +export async function loadDriverExecutionContexts(accessToken: string): Promise { + const data = await request(`${root}/executions`, accessToken, { method: 'GET' }, + (value): value is { items: DriverExecutionContext[]; limit: 100 } => + hasOnlyKeys(value, ['items', 'limit']) && value.limit === 100 && Array.isArray(value.items) + && value.items.length <= 100 && value.items.every(isExecution)); + return data.items; +} + +export async function startDriverExecution( + accessToken: string, + executionContextId: string, + input: DriverOperationalCommandInput, +): Promise { + if (!isUuid(executionContextId) || !hasOnlyKeys(input, commandKeys) || !isCommand(input)) badRequest(); + return request(`${root}/executions/${executionContextId}/start`, accessToken, post(input), + (value): value is DriverStartExecutionResult => isStartResult(value) && matchesCommand(value, executionContextId, input)); +} + +export async function reportDriverDeliveryException( + accessToken: string, + executionContextId: string, + input: DriverDeliveryExceptionInput, +): Promise { + if (!isUuid(executionContextId) || !hasOnlyKeys(input, [...commandKeys, 'targetStopId', 'reasonCode', 'explanation']) + || !isCommand(input) || !isUuid(input.targetStopId) || !isBoundedText(input.reasonCode, 80) + || (input.explanation !== undefined && input.explanation !== null && !isBoundedText(input.explanation, 1_000))) badRequest(); + // D05 defines no approved fleet catalog. Send the caller's injected reason. + return request(`${root}/executions/${executionContextId}/delivery-exceptions`, accessToken, post(input), + (value): value is DriverDeliveryExceptionResult => isExceptionResult(value) && matchesCommand(value, executionContextId, input)); +} + +export async function loadDriverOperationalInbox(accessToken: string, cursor?: string): Promise { + if (cursor !== undefined && (typeof cursor !== 'string' || cursor === '' || cursor.length > 300)) badRequest(); + const query = new URLSearchParams({ limit: '30' }); + if (cursor !== undefined) query.set('cursor', cursor); + return request(`${notifications}?${query}`, accessToken, { method: 'GET' }, + (value): value is DriverOperationalInboxPage => + hasOnlyKeys(value, ['items', 'nextCursor']) && Array.isArray(value.items) && value.items.length <= 30 + && value.items.every(isInboxItem) + && (value.nextCursor === null || (typeof value.nextCursor === 'string' && value.nextCursor !== '' && value.nextCursor.length <= 300))); +} + +export async function resolveDriverOperationalNotification(accessToken: string, id: string): Promise { + if (!isUuid(id)) badRequest(); + return request(`${notifications}/${id}/resolve`, accessToken, { method: 'GET' }, + (value): value is DriverNotificationResolution => isResolution(value) && value.notificationId === id); +} + +export async function acknowledgeDriverOperationalNotification( + accessToken: string, + id: string, + ackKind: 'READ' | 'OPENED', +): Promise { + if (!isUuid(id) || !['READ', 'OPENED'].includes(ackKind)) badRequest(); + return request(`${notifications}/${id}/acks`, accessToken, post({ ackKind }), + (value): value is DriverOperationalNotificationAck => + hasOnlyKeys(value, ['ackedAt', 'notificationId']) && isInstant(value.ackedAt) && value.notificationId === id); +} + +export async function registerDriverOperationalCapability( + accessToken: string, + input: DriverOperationalCapabilityInput, +): Promise { + if (!hasOnlyKeys(input, ['installationId', 'tokenId', 'schemaVersion', 'kinds']) + || !isBoundedText(input.installationId, 160) || !isUuid(input.tokenId) || input.schemaVersion !== 1 + || !isKindList(input.kinds)) badRequest(); + return request(`${notifications}/capability`, accessToken, post(input), + (value): value is DriverOperationalCapability => + hasOnlyKeys(value, ['capabilityId', 'kinds', 'schemaVersion']) && isUuid(value.capabilityId) + && value.schemaVersion === 1 && isKindList(value.kinds) + && value.kinds.length === input.kinds.length && value.kinds.every((kind) => input.kinds.includes(kind))); +} diff --git a/src/api/dsvDriverPushToken.test.ts b/src/api/dsvDriverPushToken.test.ts index c74cfde..31d1016 100644 --- a/src/api/dsvDriverPushToken.test.ts +++ b/src/api/dsvDriverPushToken.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, it } from 'node:test'; import { registerDriverPushToken, revokeDriverPushToken, + registerDriverPushTokenWithCapability, } from './dsvDriverPushToken'; const ORIGINAL_BASE_URL = process.env.EXPO_PUBLIC_DSV_API_BASE_URL; @@ -20,12 +21,12 @@ describe('DSV driver push token API client', () => { globalThis.fetch = async (input, init) => { request = { input: input.toString(), init }; return new Response(JSON.stringify({ - data: { pushToken: { id: 'push-1', status: 'ACTIVE' } }, + data: { pushToken: { id: '31200000-0000-4000-8000-000000000001', status: 'ACTIVE' } }, error: null, })); }; - await registerDriverPushToken('account-token', { + const result = await registerDriverPushToken('account-token', { appId: 'com.evnsolution.clever.driver', appVersion: '0.1.3', deviceId: 'android-device', @@ -35,6 +36,7 @@ describe('DSV driver push token API client', () => { timezone: 'Asia/Seoul', }); + assert.deepEqual(result, { tokenId: '31200000-0000-4000-8000-000000000001' }); assert.equal(request?.input, 'https://dsv.example.test/api/driver/mobile/push-token'); assert.equal(request?.init?.method, 'PUT'); assert.equal( @@ -72,3 +74,66 @@ describe('DSV driver push token API client', () => { }); }); }); + + +describe('Driver capability registration after each FCM registration', () => { + afterEach(() => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = ORIGINAL_BASE_URL; + delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('uses the returned token ID and the same installation ID after token renewal', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + const order: string[] = []; + let ordinal = 0; + globalThis.fetch = async (_input, init) => { + const body = JSON.parse(init?.body as string); + order.push(`token:${body.devicePushToken}`); + return new Response(JSON.stringify({ data: { pushToken: { + id: `31200000-0000-4000-8000-00000000000${++ordinal}`, status: 'ACTIVE', + } }, error: null })); + }; + const registerCapability = async (_accessToken: string, input: { + installationId: string; tokenId: string; schemaVersion: 1; kinds: readonly string[]; + }) => { + order.push(`capability:${input.tokenId}`); + assert.equal(input.installationId, 'android-device'); + assert.equal(input.schemaVersion, 1); + assert.deepEqual(input.kinds, ['N01', 'N02', 'N03', 'N04', 'N05', 'N06']); + }; + for (const devicePushToken of ['first-fcm', 'renewed-fcm']) { + await registerDriverPushTokenWithCapability('account-token', { + appId: 'com.evnsolution.clever.driver', deviceId: 'android-device', devicePushToken, platform: 'android', + }, { operationalEnabled: true, registerCapability }); + } + assert.deepEqual(order, [ + 'token:first-fcm', 'capability:31200000-0000-4000-8000-000000000001', + 'token:renewed-fcm', 'capability:31200000-0000-4000-8000-000000000002', + ]); + }); + + it('keeps capability disabled by default and refuses a missing installation ID', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + globalThis.fetch = async () => new Response(JSON.stringify({ data: { pushToken: { + id: '31200000-0000-4000-8000-000000000001', status: 'ACTIVE', + } }, error: null })); + let capabilities = 0; + const registerCapability = async () => { capabilities += 1; }; + const registration = { appId: 'com.evnsolution.clever.driver', devicePushToken: 'fcm', platform: 'android' as const }; + await registerDriverPushTokenWithCapability('token', registration, { operationalEnabled: false, registerCapability }); + assert.equal(capabilities, 0); + await assert.rejects(registerDriverPushTokenWithCapability('token', registration, + { operationalEnabled: true, registerCapability }), /DRIVER_PUSH_INSTALLATION_REQUIRED/); + assert.equal(capabilities, 0); + }); + + it('rejects a malformed token registration before any capability is advertised', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + for (const pushToken of [{ id: 'symbolic-id', status: 'ACTIVE' }, { id: '31200000-0000-4000-8000-000000000001', status: 'REVOKED' }]) { + globalThis.fetch = async () => new Response(JSON.stringify({ data: { pushToken }, error: null })); + await assert.rejects(registerDriverPushToken('token', { + appId: 'com.evnsolution.clever.driver', devicePushToken: 'fcm', platform: 'android', + }), /DRIVER_PUSH_TOKEN_INVALID_RESPONSE/); + } + }); +}); diff --git a/src/api/dsvDriverPushToken.ts b/src/api/dsvDriverPushToken.ts index 1697ba1..1874895 100644 --- a/src/api/dsvDriverPushToken.ts +++ b/src/api/dsvDriverPushToken.ts @@ -1,3 +1,7 @@ +import { + DRIVER_OPERATIONAL_NOTIFICATION_KINDS, + type DriverOperationalPushKind, +} from '../domain/notifications/driverPushNotification'; import { resolveDsvApiUrl } from './dsvApiUrl'; export type DriverPushTokenRegistration = { @@ -9,36 +13,67 @@ export type DriverPushTokenRegistration = { platform: 'android'; timezone?: string; }; +export type DriverPushTokenResult = { tokenId: string }; +type RegisterCapability = (accessToken: string, input: { + installationId: string; + kinds: DriverOperationalPushKind[]; + schemaVersion: 1; + tokenId: string; +}) => Promise; -export function registerDriverPushToken( +export async function registerDriverPushToken( accessToken: string, registration: DriverPushTokenRegistration, -): Promise { - return request(accessToken, 'PUT', registration); +): Promise { + const response = await request(accessToken, 'PUT', registration); + const value: unknown = await response.json(); + if (!isRecord(value) || value.error !== null || !isRecord(value.data) + || !isRecord(value.data.pushToken) || value.data.pushToken.status !== 'ACTIVE' + || typeof value.data.pushToken.id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value.data.pushToken.id)) { + throw new Error('DRIVER_PUSH_TOKEN_INVALID_RESPONSE'); + } + return { tokenId: value.data.pushToken.id }; } -export function revokeDriverPushToken( +/** Token renewal invalidates server capability. Advertise again only after the token PUT succeeds. */ +export async function registerDriverPushTokenWithCapability( accessToken: string, - devicePushToken: string, -): Promise { - return request(accessToken, 'DELETE', { devicePushToken }); + registration: DriverPushTokenRegistration, + options: { operationalEnabled: boolean; registerCapability: RegisterCapability; isCurrent?: () => boolean; onTokenRegistered?: () => Promise }, +): Promise { + if (options.operationalEnabled && !registration.deviceId?.trim()) { + throw new Error('DRIVER_PUSH_INSTALLATION_REQUIRED'); + } + const result = await registerDriverPushToken(accessToken, registration); + await options.onTokenRegistered?.(); + if (options.operationalEnabled && (options.isCurrent?.() ?? true)) { + await options.registerCapability(accessToken, { + installationId: registration.deviceId!, + kinds: [...DRIVER_OPERATIONAL_NOTIFICATION_KINDS], + schemaVersion: 1, + tokenId: result.tokenId, + }); + } + return result; } +export async function revokeDriverPushToken(accessToken: string, devicePushToken: string): Promise { + await request(accessToken, 'DELETE', { devicePushToken }); +} async function request( accessToken: string, method: 'DELETE' | 'PUT', body: DriverPushTokenRegistration | { devicePushToken: string }, -): Promise { +): Promise { const response = await fetch(resolveDsvApiUrl('/api/driver/mobile/push-token'), { body: JSON.stringify(body), - headers: new Headers({ - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }), + headers: new Headers({ Accept: 'application/json', Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }), method, }); - if (!response.ok) { - throw new Error(`DRIVER_PUSH_TOKEN_HTTP_${response.status}`); - } + if (!response.ok) throw new Error(`DRIVER_PUSH_TOKEN_HTTP_${response.status}`); + return response; +} +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/app/AppRoot.tsx b/src/app/AppRoot.tsx index 3dbc82c..b1a871b 100644 --- a/src/app/AppRoot.tsx +++ b/src/app/AppRoot.tsx @@ -4,6 +4,7 @@ import { ActivityIndicator, Alert, AppState, + BackHandler, Linking, Platform, Pressable, @@ -47,10 +48,20 @@ import { registerExpoDriverPushNotifications, revokeExpoDriverPushNotifications, subscribeToExpoDriverPushNotifications, + subscribeToExpoDriverNotificationClicks, + createExpoDriverNotificationRecovery, + clearExpoDriverNotificationResponse, + type DriverPushRegistrationState, } from '../platform/expo/notifications/expoDriverNotificationService'; import { DriverAppUpdateScreen } from '../ui/appUpdate/DriverAppUpdateScreen'; import { AuthEntryScreen } from '../ui/auth/AuthEntryScreen'; import { DriverWorkspace } from '../ui/driver/DriverWorkspace'; +import { DriverNotificationNotice, DriverOperationalInbox } from '../ui/driver/DriverOperationalInbox'; +import { DRIVER_OPERATIONAL_ENABLED } from '../config/driverOperational'; +import { acknowledgeDriverOperationalNotification, DriverOperationalApiError, loadDriverOperationalInbox } from '../api/dsvDriverOperational'; +import { isDriverOperationalPushNotification } from '../domain/notifications/driverPushNotification'; +import type { DriverNotificationClickLease } from '../domain/notifications/driverNotificationRecovery'; +import { resolveDriverNotificationClick, type DriverNotificationDestination } from './driverNotificationNavigation'; const INSTALLED_APP_VERSION = readInstalledDriverAppVersion(); const CAN_CHECK_ANDROID_APP_UPDATE = @@ -64,7 +75,7 @@ const INITIAL_APP_UPDATE_STATE: DriverAppUpdateState = const APP_UPDATE_RECHECK_INTERVAL_MS = 5 * 60 * 1_000; const APP_UPDATE_FAILURE_RETRY_INTERVAL_MS = 5 * 60 * 1_000; -export function AppRoot() { +export function AppRoot({ deliveryExceptionReasons = [] }: { shell?: boolean; deliveryExceptionReasons?: readonly { code: string; label: string; requiresExplanation?: boolean }[] } = {}) { const [appUpdateState, setAppUpdateState] = useState(INITIAL_APP_UPDATE_STATE); const [dismissedOptionalVersionCode, setDismissedOptionalVersionCode] = useState(null); const [authSession, setAuthSession] = useState(null); @@ -78,6 +89,26 @@ export function AppRoot() { const lastAppUpdateCheckAt = useRef(null); const lastAppUpdateCheckSucceeded = useRef(false); const isMounted = useRef(true); + const [notificationRecovery] = useState(createExpoDriverNotificationRecovery); + const [clickAttempt, setClickAttempt] = useState(0); + const [notificationDestination, setNotificationDestination] = useState(); + const [notificationNotice, setNotificationNotice] = useState<{ message: string; retryable: boolean; ackOnClose?: boolean }>(); + const [pushRegistration, setPushRegistration] = useState(); + const [isInboxOpen, setIsInboxOpen] = useState(false); + const clickLease = useRef(null); + const authGeneration = useRef(0); + const authWrites = useRef>(Promise.resolve()); + const accountIdRef = useRef(null); + const authRecoveryInFlight = useRef<{ generation: number; promise: Promise } | null>(null); + + useEffect(() => { + void notificationRecovery.restore().then(() => setClickAttempt((value) => value + 1)).catch(() => setNotificationNotice({ message: '저장된 알림을 복구하지 못했습니다. 다시 눌러 주세요.', retryable: false })); + return subscribeToExpoDriverNotificationClicks((notification) => { + void notificationRecovery.receiveClick(notification) + .then(() => setClickAttempt((value) => value + 1)) + .catch(() => setNotificationNotice({ message: '알림 이동을 저장하지 못했습니다. 다시 눌러 주세요.', retryable: false })); + }); + }, [notificationRecovery]); const checkForAppUpdate = useCallback(async (force = false) => { if (!CAN_CHECK_ANDROID_APP_UPDATE || INSTALLED_APP_VERSION === null) { @@ -141,40 +172,94 @@ export function AppRoot() { }; }, [checkForAppUpdate]); - const acceptAuthSession = useCallback(async (session: DriverAuthSession) => { - await saveDriverAuthSession(session); - setAutoLoginEnabled(true); - setHasAutoLoginConnectionError(false); - setAuthSession(session); - }, []); + const acceptAuthSession = useCallback(async (session: DriverAuthSession, expectedGeneration?: number) => { + if (expectedGeneration === undefined && accountIdRef.current !== null && accountIdRef.current !== session.account.id) authGeneration.current += 1; + const generation = expectedGeneration ?? authGeneration.current; + const write = authWrites.current.then(async () => { + if (generation !== authGeneration.current) return; + if (expectedGeneration !== undefined && accountIdRef.current !== null && accountIdRef.current !== session.account.id) throw new Error('AUTH_ACCOUNT_CHANGED'); + await saveDriverAuthSession(session); + if (generation !== authGeneration.current) return; + const accountChanged = accountIdRef.current !== null && accountIdRef.current !== session.account.id; + await notificationRecovery.setAccount(session.account.id); + if (generation !== authGeneration.current) return; + if (accountChanged) { + await clearExpoDriverNotificationResponse(); + clickLease.current = null; + setNotificationDestination(undefined); + setNotificationNotice(undefined); + setIsInboxOpen(false); + } + accountIdRef.current = session.account.id; + setAutoLoginEnabled(true); + setHasAutoLoginConnectionError(false); + setAuthSession(session); + setClickAttempt((value) => value + 1); + }); + authWrites.current = write.catch(() => undefined); + await write; + }, [notificationRecovery]); const discardAuthSession = useCallback(async () => { + authGeneration.current += 1; + await notificationRecovery.setAccount(null); + clickLease.current = null; + setNotificationDestination(undefined); + setNotificationNotice(undefined); + setIsInboxOpen(false); setAutoLoginEnabled(false); setHasAutoLoginConnectionError(false); setIsRestoringSession(false); setAuthSession(null); - await clearDriverAuthSession(); - }, []); + const write = authWrites.current.then(clearDriverAuthSession); + authWrites.current = write.catch(() => undefined); + await write; + }, [notificationRecovery]); const logout = useCallback(async () => { - if (authSession !== null) { - try { - await revokeExpoDriverPushNotifications(authSession.accessToken); - } catch { - // Logout must remain available when the push-token endpoint is unavailable. - } - } + const previous = authSession; + const revoke = previous === null ? Promise.resolve() : revokeExpoDriverPushNotifications(previous.accessToken).catch(() => undefined); + // Clear identity synchronously before any remote revocation can wait. + const clearClicks = notificationRecovery.clearForLogout(); + accountIdRef.current = null; await discardAuthSession(); - }, [authSession, discardAuthSession]); + await clearClicks; + await clearExpoDriverNotificationResponse(); + await revoke; + }, [authSession, discardAuthSession, notificationRecovery]); + + const recoverAuthentication = useCallback(async () => { + if (authSession === null) return; + const generation = authGeneration.current; + if (authRecoveryInFlight.current?.generation === generation) return authRecoveryInFlight.current.promise; + const promise = refreshDriverAccountSession({ refreshToken: authSession.refreshToken }) + .then((session) => acceptAuthSession(session, generation)) + .catch(async (error: unknown) => { + if (generation !== authGeneration.current) return; + if (resolveDriverAuthRecoveryAction(error) === 'discard') await discardAuthSession(); + else throw error; + }) + .finally(() => { if (authRecoveryInFlight.current?.promise === promise) authRecoveryInFlight.current = null; }); + authRecoveryInFlight.current = { generation, promise }; + await promise; + }, [acceptAuthSession, authSession, discardAuthSession]); + + const recoverCommandAuthentication = useCallback(() => { + void recoverAuthentication().catch(() => setNotificationNotice({ message: '로그인 연결을 복구하지 못했습니다. 연결 후 다시 시도해 주세요.', retryable: true })); + }, [recoverAuthentication]); useEffect(() => { if (authSession === null) return undefined; - void registerExpoDriverPushNotifications(authSession.accessToken).catch(() => undefined); - return subscribeToExpoDriverPushNotifications( + let active = true; + const registrationChanged = (state: DriverPushRegistrationState) => { if (active) setPushRegistration(state); }; + void registerExpoDriverPushNotifications(authSession.accessToken).then(registrationChanged).catch(() => registrationChanged({ status: 'registration-error' })); + const unsubscribe = subscribeToExpoDriverPushNotifications( authSession.accessToken, () => setNotificationRefreshKey((key) => key + 1), + registrationChanged, ); - }, [authSession]); + return () => { active = false; unsubscribe(); }; + }, [authSession, notificationRefreshKey]); useEffect(() => { if (!autoLoginEnabled) { @@ -182,6 +267,7 @@ export function AppRoot() { } let isActive = true; + const generation = authGeneration.current; let retryTimeout: ReturnType | undefined; void readDriverAuthRefreshToken() .then(async (refreshToken) => { @@ -191,7 +277,7 @@ export function AppRoot() { } const session = await refreshDriverAccountSession({ refreshToken }); if (!isActive) return; - await acceptAuthSession(session); + await acceptAuthSession(session, generation); }) .catch(async (error: unknown) => { if (resolveDriverAuthRecoveryAction(error) === 'discard') { @@ -224,12 +310,15 @@ export function AppRoot() { const refreshAt = Date.parse(authSession.expiresAt) - 60_000; const delay = Math.max(0, Math.min(refreshAt - Date.now(), 2_147_000_000)); let timeout: ReturnType; + let active = true; + const generation = authGeneration.current; const refreshSession = () => { void refreshDriverAccountSession({ refreshToken: authSession.refreshToken, }) - .then(acceptAuthSession) + .then((session) => { if (active) return acceptAuthSession(session, generation); }) .catch((error: unknown) => { + if (!active || generation !== authGeneration.current) return; if (resolveDriverAuthRecoveryAction(error) === 'discard') { void discardAuthSession(); return; @@ -238,9 +327,160 @@ export function AppRoot() { }); }; timeout = setTimeout(refreshSession, delay); - return () => clearTimeout(timeout); + return () => { active = false; clearTimeout(timeout); }; }, [acceptAuthSession, authSession, discardAuthSession]); + useEffect(() => { + if (authSession === null || clickLease.current !== null) return; + const lease = notificationRecovery.acquirePending(); + if (lease === null) return; + clickLease.current = lease; + if (!DRIVER_OPERATIONAL_ENABLED && isDriverOperationalPushNotification(lease.notification)) { + void Promise.resolve().then(() => { + if (notificationRecovery.isCurrent(lease)) { + setIsInboxOpen(false); + setNotificationNotice({ message: '운영 알림 기능은 검증 서버 연결 후 사용할 수 있습니다.', retryable: false }); + } + }); + return; + } + const generation = authGeneration.current; + void resolveDriverNotificationClick(lease.notification, authSession.accessToken) + .then(async (navigation) => { + if (!notificationRecovery.isCurrent(lease)) return; + setIsInboxOpen(false); + if (navigation.kind === 'destination') setNotificationDestination(navigation.destination); + else if (navigation.kind === 'notice') setNotificationNotice({ message: navigation.message, retryable: false, ackOnClose: isDriverOperationalPushNotification(lease.notification) && lease.notification.status === 'current' }); + else { + setNotificationRefreshKey((key) => key + 1); + const accepted = await notificationRecovery.accept(lease); + if (!accepted || generation !== authGeneration.current || accountIdRef.current !== lease.accountId) return; + await clearExpoDriverNotificationResponse().catch(() => undefined); + if (generation !== authGeneration.current || clickLease.current !== lease || accountIdRef.current !== lease.accountId) return; + clickLease.current = null; + setClickAttempt((value) => value + 1); + } + }) + .catch(async (error: unknown) => { + if (!notificationRecovery.isCurrent(lease)) return; + if (error instanceof DriverOperationalApiError && error.status === 401) { + notificationRecovery.release(lease); + clickLease.current = null; + try { await recoverAuthentication(); } + catch { if (generation === authGeneration.current) setNotificationNotice({ message: '로그인 연결을 복구하지 못했습니다. 다시 시도해 주세요.', retryable: true }); } + return; + } + const unavailable = error instanceof DriverOperationalApiError && [403, 404, 409, 410].includes(error.status); + setNotificationNotice({ message: unavailable + ? '배차가 취소·재배정되었거나 알림이 만료되었습니다. 최신 알림함을 확인해 주세요.' + : '알림 화면을 확인하지 못했습니다. 연결 복구 후 다시 시도해 주세요.', retryable: !unavailable }); + }); + }, [authSession, clickAttempt, notificationRecovery, recoverAuthentication]); + + const acceptNotificationDestination = useCallback(async (notificationId: string) => { + const generation = authGeneration.current; + const lease = clickLease.current; + if (lease === null || authSession === null || lease.notification.notificationId !== notificationId || !notificationRecovery.isCurrent(lease)) return; + try { + if (isDriverOperationalPushNotification(lease.notification)) { + await acknowledgeDriverOperationalNotification(authSession.accessToken, notificationId, 'OPENED'); + } + if (!notificationRecovery.isCurrent(lease)) return; + const accepted = await notificationRecovery.accept(lease); + if (!accepted || generation !== authGeneration.current || accountIdRef.current !== lease.accountId) return; + await clearExpoDriverNotificationResponse().catch(() => undefined); + if (generation !== authGeneration.current || clickLease.current !== lease || accountIdRef.current !== lease.accountId) return; + clickLease.current = null; + setNotificationDestination(undefined); + setNotificationNotice(undefined); + setClickAttempt((value) => value + 1); + } catch { + if (notificationRecovery.isCurrent(lease)) setNotificationNotice({ message: '알림 열람 기록을 저장하지 못했습니다. 다시 시도해 주세요.', retryable: true }); + } + }, [authSession, notificationRecovery]); + + const rejectNotificationDestination = useCallback((notificationId: string) => { + const lease = clickLease.current; + if (lease === null || lease.notification.notificationId !== notificationId || !notificationRecovery.isCurrent(lease)) return; + setNotificationNotice({ message: '현재 배정된 배송지를 확인할 수 없습니다. 최신 배차를 확인해 주세요.', retryable: false }); + }, [notificationRecovery]); + + function retryNotification() { + const lease = clickLease.current; + if (lease !== null) notificationRecovery.release(lease); + clickLease.current = null; + setNotificationDestination(undefined); + setNotificationNotice(undefined); + setClickAttempt((value) => value + 1); + } + + const dismissNotification = useCallback(async () => { + const lease = clickLease.current; + const generation = authGeneration.current; + const notice = notificationNotice; + const remainsCurrent = () => generation === authGeneration.current && clickLease.current === lease + && (lease === null || accountIdRef.current === lease.accountId); + if (lease !== null) { + if (notice?.ackOnClose && authSession !== null) { + try { await acknowledgeDriverOperationalNotification(authSession.accessToken, lease.notification.notificationId, 'OPENED'); } + catch { + if (remainsCurrent() && notificationRecovery.isCurrent(lease)) setNotificationNotice({ message: '알림 열람 기록을 저장하지 못했습니다. 다시 시도해 주세요.', retryable: true }); + return; + } + if (!remainsCurrent() || !notificationRecovery.isCurrent(lease)) return; + } + if (notice?.retryable) notificationRecovery.release(lease); + else { + try { + const accepted = await notificationRecovery.accept(lease); + if (!accepted || !remainsCurrent()) return; + } catch { + if (remainsCurrent()) setNotificationNotice({ message: '알림 처리 기록을 저장하지 못했습니다. 다시 시도해 주세요.', retryable: true }); + return; + } + } + } + if (!remainsCurrent()) return; + clickLease.current = null; + setNotificationDestination(undefined); + setNotificationNotice(undefined); + if (!notice?.retryable) { + await clearExpoDriverNotificationResponse().catch(() => undefined); + if (generation === authGeneration.current && clickLease.current === null) setClickAttempt((value) => value + 1); + } + }, [authSession, notificationNotice, notificationRecovery]); + + useEffect(() => { + if (Platform.OS !== 'android' || authSession === null || (!isInboxOpen && notificationNotice === undefined)) return; + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (notificationNotice !== undefined) void dismissNotification(); + else setIsInboxOpen(false); + return true; + }); + return () => subscription.remove(); + }, [authSession, dismissNotification, isInboxOpen, notificationNotice]); + + async function openInboxNotification(notificationId: string) { + if (authSession === null) return; + const generation = authGeneration.current; + const accountId = authSession.account.id; + // Inbox identity is reloaded from the authenticated list; its summary is never a destination. + try { + let page = await loadDriverOperationalInbox(authSession.accessToken); + let item = page.items.find((entry) => entry.id === notificationId); + while (item === undefined && page.nextCursor !== null) { + if (generation !== authGeneration.current || accountIdRef.current !== accountId) return; + page = await loadDriverOperationalInbox(authSession.accessToken, page.nextCursor); + item = page.items.find((entry) => entry.id === notificationId); + } + if (item === undefined || generation !== authGeneration.current || accountIdRef.current !== accountId) return; + await notificationRecovery.receiveClick({ kind: item.kind, notificationId, schemaVersion: '1', expiresAt: item.expiresAt, status: Date.parse(item.expiresAt) > Date.now() ? 'current' : 'expired' }, { reopen: true }); + if (generation !== authGeneration.current || accountIdRef.current !== accountId) return; + setClickAttempt((value) => value + 1); + setIsInboxOpen(false); + } catch { if (generation === authGeneration.current && accountIdRef.current === accountId) setNotificationNotice({ message: '알림을 확인하지 못했습니다. 다시 시도해 주세요.', retryable: true }); } + } + const canPresentAppUpdate = authSession !== null || !isRestoringSession; const shouldShowAppUpdate = canPresentAppUpdate && shouldPresentDriverAppUpdate({ dismissedOptionalVersionCode, @@ -317,11 +557,28 @@ export function AppRoot() { ) : authSession === null ? ( ) : ( - + + + {pushRegistration?.status === 'permission-denied' ? { void Linking.openSettings(); }} style={styles.inboxButton}>알림 권한이 꺼져 있습니다. 설정 열기 : null} + {DRIVER_OPERATIONAL_ENABLED ? setIsInboxOpen(true)} style={styles.inboxButton}>알림함 : null} + + + {notificationNotice !== undefined ? ( + { void dismissNotification(); }} onRetry={notificationNotice.retryable ? retryNotification : undefined} /> + ) : isInboxOpen ? ( + setIsInboxOpen(false)} onOpen={(id) => { void openInboxNotification(id); }} /> + ) : null} + )} @@ -334,6 +591,8 @@ const styles = StyleSheet.create({ root: { flex: 1, }, + hiddenWorkspace: { display: 'none' }, + inboxButton: { alignItems: 'flex-end', paddingHorizontal: 18, paddingVertical: 10, backgroundColor: '#fff' }, safeArea: { flex: 1, backgroundColor: '#f7f9fc', diff --git a/src/app/driverNotificationAppRoot.test.ts b/src/app/driverNotificationAppRoot.test.ts new file mode 100644 index 0000000..fc8d19e --- /dev/null +++ b/src/app/driverNotificationAppRoot.test.ts @@ -0,0 +1,510 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; + +import type { DriverAuthSession } from '../api/dsvDriverAuth'; +import { DriverOperationalApiError, type DriverNotificationResolution } from '../api/dsvDriverOperational'; +import * as recoveryPolicy from '../auth/driverAuthRecovery'; +import * as appUpdate from '../domain/appUpdate/driverAppUpdate'; +import { createDriverNotificationRecovery } from '../domain/notifications/driverNotificationRecovery'; +import * as payloads from '../domain/notifications/driverPushNotification'; +import type { DriverPushRegistrationState } from '../platform/expo/notifications/expoDriverNotificationService'; +import { resolveDriverNotificationClick } from './driverNotificationNavigation'; + +type Element = { type: unknown; props: Record }; +type WorkspaceProps = Parameters[0]; +const ID = '31200000-0000-4000-8000-000000000001'; +const click: payloads.DriverNotificationClick = { + notificationId: ID, kind: 'N06', schemaVersion: '1', expiresAt: '2099-10-07T01:00:00.000Z', status: 'current', +}; +const destination: DriverNotificationResolution = { + notificationId: ID, + destination: { type: 'EXECUTION', executionContextId: '31200000-0000-4000-8000-000000000002', routePlanId: 'route-A', targetStopId: 'delivery-stop-A' }, +}; +function auth(accountId = 'account-A'): DriverAuthSession { + return { + account: { id: accountId, loginId: accountId, name: accountId, phone: '+821012345678', connectionStatus: 'LINKED', linkedDrivers: [] }, + accessToken: `${accountId}-access`, expiresAt: '2099-10-07T00:00:00.000Z', refreshToken: `${accountId}-refresh`, + refreshTokenExpiresAt: '2099-11-07T00:00:00.000Z', tokenType: 'Bearer', ttlSeconds: 3600, use: 'dsv_driver_account', + }; +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((accept, fail) => { resolve = accept; reject = fail; }); + return { promise, resolve, reject }; +} +function memoryStorage() { + const values = new Map(); + return { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { values.set(key, value); }, + removeItem: async (key: string) => { values.delete(key); }, + snapshot: () => [...values.values()], + }; +} +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +// Executes production AppRoot effects/handlers and the real identity coordinator. +// Native layout, FCM delivery, and OS process launch still require device evidence. +function appRootHarness(options: { + storage?: ReturnType; + coldClick?: payloads.DriverNotificationClick; + resolver?: (token: string, id: string) => Promise; + refresh?: () => Promise; + registration?: () => Promise; + acknowledge?: (token: string, id: string, kind: string) => Promise; + clearNativeResponse?: () => Promise; +} = {}) { + const storage = options.storage ?? memoryStorage(); + const coordinator = createDriverNotificationRecovery(storage); + const slots: unknown[] = []; + type Effect = { deps: unknown[]; cleanup?: void | (() => void) }; + const effects = new Map(); + const pending: (() => void)[] = []; + const timeouts = new Map(); + const resolutions: { token: string; id: string }[] = []; + const acknowledgements: { token: string; id: string; kind: string }[] = []; + const savedSessions: DriverAuthSession[] = []; + let cursor = 0; + let dirty = false; + let tree: unknown; + let timeoutId = 0; + let clearedNativeResponses = 0; + let refreshRequests = 0; + let lateWrites = 0; + let active = true; + let onClick: ((notification: payloads.DriverNotificationClick) => void) | undefined; + let onBack: (() => boolean) | undefined; + let onActive: ((state: string) => void) | undefined; + let authenticate: ((session: DriverAuthSession) => Promise) | undefined; + const same = (previous: unknown[] | undefined, next: unknown[]) => previous?.length === next.length && previous.every((value, i) => Object.is(value, next[i])); + const jsx = (type: unknown, props: Record) => ({ type, props }); + const dependencies: Record = { + react: { + useRef: (initial: unknown) => { const index = cursor++; return slots[index] ?? (slots[index] = { current: initial }); }, + useState: (initial: unknown) => { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial; + return [slots[index], (value: unknown) => { + if (!active) lateWrites += 1; + const next = typeof value === 'function' ? value(slots[index]) : value; + if (!Object.is(next, slots[index])) { slots[index] = next; dirty = true; } + }]; + }, + useCallback: (callback: unknown, deps: unknown[]) => { + const index = cursor++; + const old = slots[index] as { callback: unknown; deps: unknown[] } | undefined; + if (!same(old?.deps, deps)) slots[index] = { callback, deps }; + return (slots[index] as { callback: unknown }).callback; + }, + useEffect: (setup: () => void | (() => void), deps: unknown[]) => { + const index = cursor++; const old = effects.get(index); + if (!same(old?.deps, deps)) pending.push(() => { old?.cleanup?.(); effects.set(index, { deps, cleanup: setup() }); }); + }, + }, + 'react/jsx-runtime': { jsx, jsxs: jsx, Fragment: 'Fragment' }, + 'expo-status-bar': { StatusBar: 'StatusBar' }, + 'react-native': { + Platform: { OS: 'android' }, StyleSheet: { create: (styles: unknown) => styles }, + AppState: { currentState: 'active', addEventListener: (_name: string, callback: (state: string) => void) => { + onActive = callback; return { remove: () => { onActive = undefined; } }; + } }, + BackHandler: { addEventListener: (_name: string, callback: () => boolean) => { onBack = callback; return { remove: () => { if (onBack === callback) onBack = undefined; } }; } }, + Alert: { alert: () => undefined }, Linking: { openURL: async () => undefined, openSettings: async () => undefined }, + ActivityIndicator: 'ActivityIndicator', Pressable: 'Pressable', Text: 'Text', View: 'View', + }, + 'react-native-gesture-handler': { GestureHandlerRootView: 'GestureRoot' }, + 'react-native-keyboard-controller': { KeyboardProvider: 'KeyboardProvider' }, + 'react-native-safe-area-context': { SafeAreaProvider: 'SafeAreaProvider', SafeAreaView: 'SafeAreaView' }, + '../api/dsvDriverAppRelease': { fetchDriverAndroidAppRelease: async () => { throw new Error('Unexpected update request'); } }, + '../api/dsvDriverAuth': { refreshDriverAccountSession: async () => { refreshRequests += 1; return options.refresh?.() ?? auth(); } }, + '../auth/driverAuthRecovery': recoveryPolicy, + '../auth/driverAuthSessionStore': { + saveDriverAuthSession: async (session: DriverAuthSession) => { savedSessions.push(session); }, + readDriverAuthRefreshToken: async () => null, clearDriverAuthSession: async () => undefined, + }, + '../config/driverAppInstall': { DRIVER_APP_INSTALL_PAGE_URL: 'https://example.test/install', isProductionDriverAndroidPackage: () => false }, + '../domain/appUpdate/driverAppUpdate': appUpdate, + '../platform/expo/application/expoAppVersionService': { readInstalledDriverAppVersion: () => null }, + '../platform/expo/notifications/expoDriverNotificationService': { + createExpoDriverNotificationRecovery: () => coordinator, + registerExpoDriverPushNotifications: options.registration ?? (async () => ({ status: 'registered' })), + subscribeToExpoDriverNotificationClicks: (callback: typeof onClick) => { + onClick = callback; + if (options.coldClick) callback?.(options.coldClick); + return () => { onClick = undefined; }; + }, + subscribeToExpoDriverPushNotifications: () => () => undefined, + revokeExpoDriverPushNotifications: async () => undefined, + clearExpoDriverNotificationResponse: async () => { clearedNativeResponses += 1; await options.clearNativeResponse?.(); }, + }, + '../config/driverOperational': { DRIVER_OPERATIONAL_ENABLED: true }, + '../domain/notifications/driverPushNotification': payloads, + '../ui/appUpdate/DriverAppUpdateScreen': { DriverAppUpdateScreen: 'DriverAppUpdateScreen' }, + '../ui/auth/AuthEntryScreen': { AuthEntryScreen: 'AuthEntryScreen' }, + '../ui/driver/DriverWorkspace': { DriverWorkspace: 'DriverWorkspace' }, + '../ui/driver/DriverOperationalInbox': { DriverOperationalInbox: 'Inbox', DriverNotificationNotice: 'Notice' }, + '../api/dsvDriverOperational': { + DriverOperationalApiError, + loadDriverOperationalInbox: async () => ({ items: [], nextCursor: null }), + acknowledgeDriverOperationalNotification: async (token: string, id: string, kind: string) => { acknowledgements.push({ token, id, kind }); await options.acknowledge?.(token, id, kind); }, + }, + './driverNotificationNavigation': { + resolveDriverNotificationClick: (notification: payloads.DriverNotificationClick, token: string) => resolveDriverNotificationClick(notification, token, async (accessToken, id) => { + resolutions.push({ token: accessToken, id }); + return options.resolver?.(accessToken, id) ?? destination; + }), + }, + }; + const source = readFileSync(new URL('./AppRoot.tsx', import.meta.url), 'utf8'); + const { outputText } = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, + } }); + const module = { exports: {} }; + runInNewContext(outputText, { + module, exports: module.exports, Error, Date, Intl, Map, Set, Promise, process, + setTimeout: (run: () => void, delay: number) => { const id = ++timeoutId; timeouts.set(id, { delay, run }); return id; }, + clearTimeout: (id: number) => { timeouts.delete(id); }, setInterval: () => ++timeoutId, clearInterval: () => undefined, + require: (name: string) => { assert.ok(name in dependencies, `Unexpected dependency ${name}`); return dependencies[name]; }, + }); + const component = (module.exports as typeof import('./AppRoot')).AppRoot; + function nodes(value: unknown, result: Element[] = []): Element[] { + if (Array.isArray(value)) value.forEach((child) => nodes(child, result)); + else if (value !== null && typeof value === 'object' && 'props' in value) { + const element = value as Element; result.push(element); nodes(element.props.children, result); + } + return result; + } + function find(type: string) { return nodes(tree).find((element) => element.type === type); } + function render() { + cursor = 0; dirty = false; tree = component(); + pending.splice(0).forEach((effect) => effect()); + const entry = find('AuthEntryScreen'); + if (entry) authenticate = entry.props.onAuthenticated as typeof authenticate; + } + const harness = { + render, + async settle() { for (let i = 0; i < 12; i++) { await flush(); if (dirty) render(); } }, + async login(accountId = 'account-A') { assert.ok(authenticate); await authenticate(auth(accountId)); render(); await harness.settle(); }, + async logout() { const workspace = harness.workspace(); assert.ok(workspace); await workspace.onLogout(); render(); await harness.settle(); }, + workspace: () => find('DriverWorkspace')?.props as WorkspaceProps | undefined, + find, + hasText: (text: string) => nodes(tree).some((element) => element.type === 'Text' && element.props.children === text), + click(notification = click) { assert.ok(onClick); onClick(notification); }, + hardwareBack() { assert.ok(onBack); return onBack(); }, + pressText(text: string) { + const button = nodes(tree).find((element) => element.type === 'Pressable' && nodes(element.props.children).some((child) => child.type === 'Text' && child.props.children === text)); + assert.ok(button); (button.props.onPress as () => void)(); + }, + foreground() { assert.ok(onActive); onActive('active'); }, + fireAuthRefresh() { + const entry = [...timeouts.entries()].find(([, timer]) => timer.delay === 2_147_000_000); + assert.ok(entry); timeouts.delete(entry[0]); entry[1].run(); + }, + get resolutions() { return resolutions; }, + get acknowledgements() { return acknowledgements; }, + get refreshRequests() { return refreshRequests; }, + get savedSessions() { return savedSessions; }, + get clearedNativeResponses() { return clearedNativeResponses; }, + get lateWrites() { return lateWrites; }, + storage, + unmount() { active = false; for (const effect of effects.values()) effect.cleanup?.(); }, + }; + return harness; +} + +describe('Driver AppRoot synthetic click and authentication flow', () => { + it('restores a cold click before login, resolves once, and sends OPENED only after workspace acceptance', async () => { + const resolution = deferred(); + const h = appRootHarness({ coldClick: click, resolver: async () => resolution.promise }); + h.render(); await h.settle(); + assert.equal(h.resolutions.length, 0); + assert.ok(h.find('AuthEntryScreen')); + await h.login(); + assert.equal(h.resolutions.length, 1); + assert.equal(h.acknowledgements.length, 0); + h.click(); await h.settle(); + assert.equal(h.resolutions.length, 1); + resolution.resolve(destination); await h.settle(); + const workspace = h.workspace(); assert.ok(workspace); + assert.equal(workspace.notificationDestination?.targetStopId, 'delivery-stop-A'); + assert.equal(workspace.notificationDestination?.routePlanId, 'route-A'); + assert.equal(h.acknowledgements.length, 0); + assert.match(h.storage.snapshot()[0]!, /pending.*N06/u); + workspace.onNotificationDestinationAccepted?.(ID); await h.settle(); + assert.deepEqual(h.acknowledgements, [{ token: 'account-A-access', id: ID, kind: 'OPENED' }]); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.clearedNativeResponses, 1); + h.click(); await h.settle(); + assert.equal(h.resolutions.length, 1); + h.unmount(); + const restarted = appRootHarness({ storage: h.storage, coldClick: click }); + restarted.render(); await restarted.settle(); await restarted.login(); + assert.equal(restarted.resolutions.length, 0); + restarted.unmount(); + }); + + it('suppresses an old resolver after account switch and keeps old route detail out of the new account', async () => { + const resolution = deferred(); + const h = appRootHarness({ resolver: async () => resolution.promise }); + h.render(); await h.settle(); await h.login(); + h.click(); await h.settle(); + assert.equal(h.resolutions.length, 1); + await h.login('account-B'); + resolution.resolve(destination); await h.settle(); + assert.equal(h.workspace()?.authSession.account.id, 'account-B'); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.acknowledgements.length, 0); + assert.equal(h.clearedNativeResponses, 1); + h.unmount(); + }); + + it('keeps an offline unresolved click through restart and resolves it under the recovered account', async () => { + const h = appRootHarness({ coldClick: click, resolver: async () => { throw new Error('offline'); } }); + h.render(); await h.settle(); await h.login(); + assert.ok(h.find('Notice')?.props.onRetry); + assert.equal(h.acknowledgements.length, 0); + h.unmount(); + const restarted = appRootHarness({ storage: h.storage }); + restarted.render(); await restarted.settle(); await restarted.login(); + assert.equal(restarted.resolutions.length, 1); + assert.equal(restarted.workspace()?.notificationDestination?.targetStopId, 'delivery-stop-A'); + assert.equal(restarted.acknowledgements.length, 0); + restarted.unmount(); + }); + + it('does not restore a logged-out account when an automatic token refresh returns late', async () => { + const refresh = deferred(); + const h = appRootHarness({ refresh: async () => refresh.promise }); + h.render(); await h.settle(); await h.login(); + h.fireAuthRefresh(); await h.settle(); + assert.equal(h.refreshRequests, 1); + await h.logout(); + assert.ok(h.find('AuthEntryScreen')); + await h.login('account-B'); + refresh.resolve(auth()); await h.settle(); + assert.equal(h.workspace()?.authSession.account.id, 'account-B'); + assert.deepEqual(h.savedSessions.map((session) => session.account.id), ['account-A', 'account-B']); + h.unmount(); + }); + + it('does not restore a logged-out account when click-triggered authentication recovery returns late', async () => { + const refresh = deferred(); + const h = appRootHarness({ refresh: async () => refresh.promise, resolver: async () => { throw new DriverOperationalApiError(401, 'AUTH_REQUIRED'); } }); + h.render(); await h.settle(); await h.login(); + h.click(); await h.settle(); + assert.equal(h.refreshRequests, 1); + await h.logout(); await h.login('account-B'); + refresh.resolve(auth()); await h.settle(); + assert.equal(h.workspace()?.authSession.account.id, 'account-B'); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.acknowledgements.length, 0); + assert.deepEqual(h.savedSessions.map((session) => session.account.id), ['account-A', 'account-B']); + h.unmount(); + }); +}); + + +it('does not show the previous account permission result after account switch', async () => { + const first = deferred(); + let registrations = 0; + const h = appRootHarness({ registration: async () => ++registrations === 1 ? first.promise : { status: 'registered' } }); + h.render(); await h.settle(); await h.login(); + await h.login('account-B'); + first.resolve({ status: 'permission-denied', canAskAgain: false }); await h.settle(); + assert.equal(h.workspace()?.authSession.account.id, 'account-B'); + assert.equal(h.hasText('알림 권한이 꺼져 있습니다. 설정 열기'), false); + h.unmount(); +}); + +it('rechecks notification permission on foreground and removes the denial prompt after recovery', async () => { + let permission: DriverPushRegistrationState = { status: 'permission-denied', canAskAgain: false }; + const h = appRootHarness({ registration: async () => permission }); + h.render(); await h.settle(); await h.login(); + assert.equal(h.hasText('알림 권한이 꺼져 있습니다. 설정 열기'), true); + permission = { status: 'registered' }; + h.foreground(); h.render(); await h.settle(); + assert.equal(h.hasText('알림 권한이 꺼져 있습니다. 설정 열기'), false); + h.unmount(); +}); + +it('shows N03 release facts and acknowledges only after the notice is accepted', async () => { + const released = { ...click, kind: 'N03' as const }; + const h = appRootHarness({ coldClick: released, resolver: async () => ({ notificationId: ID, destination: { type: 'ASSIGNMENT_RELEASED' } }) }); + h.render(); await h.settle(); await h.login(); + const notice = h.find('Notice'); assert.ok(notice); + assert.equal(notice.props.message, '이 배차의 배정이 해제되었습니다.'); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.acknowledgements.length, 0); + (notice.props.onClose as () => void)(); await h.settle(); + assert.deepEqual(h.acknowledgements, [{ token: 'account-A-access', id: ID, kind: 'OPENED' }]); + h.unmount(); +}); + +it('shows expired and unsupported clicks without resolver or acknowledgement requests', async () => { + for (const notification of [ + { ...click, expiresAt: '2020-10-07T01:00:00.000Z', status: 'expired' as const }, + { kind: 'unsupported' as const, notificationId: 'provider-id', reason: 'UNSUPPORTED_PAYLOAD' as const }, + ]) { + const h = appRootHarness({ coldClick: notification }); + h.render(); await h.settle(); await h.login(); + const notice = h.find('Notice'); assert.ok(notice); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.resolutions.length, 0); + (notice.props.onClose as () => void)(); await h.settle(); + assert.equal(h.acknowledgements.length, 0); + h.unmount(); + } +}); + + +it('recovers a resolver 401 and retries the same click with the renewed account token', async () => { + let resolves = 0; + const renewed = { ...auth(), accessToken: 'account-A-renewed-access' }; + const h = appRootHarness({ + refresh: async () => renewed, + resolver: async () => { if (++resolves === 1) throw new DriverOperationalApiError(401, 'AUTH_REQUIRED'); return destination; }, + }); + h.render(); await h.settle(); await h.login(); + h.click(); await h.settle(); + assert.equal(h.refreshRequests, 1); + assert.deepEqual(h.resolutions, [ + { token: 'account-A-access', id: ID }, { token: 'account-A-renewed-access', id: ID }, + ]); + assert.equal(h.workspace()?.notificationDestination?.targetStopId, 'delivery-stop-A'); + assert.equal(h.acknowledgements.length, 0); + await h.workspace()?.onNotificationDestinationAccepted?.(ID); await h.settle(); + assert.deepEqual(h.acknowledgements, [{ token: 'account-A-renewed-access', id: ID, kind: 'OPENED' }]); + h.unmount(); +}); + +it('retains a click when OPENED fails and accepts it after a revalidated destination retry', async () => { + let attempts = 0; + const h = appRootHarness({ acknowledge: async () => { if (++attempts === 1) throw new Error('acknowledgement response lost'); } }); + h.render(); await h.settle(); await h.login(); + h.click(); await h.settle(); + await h.workspace()?.onNotificationDestinationAccepted?.(ID); await h.settle(); + const notice = h.find('Notice'); assert.ok(notice); + assert.ok(notice.props.onRetry); + assert.equal(h.clearedNativeResponses, 0); + assert.match(h.storage.snapshot()[0]!, /pending.*N06/u); + (notice.props.onRetry as () => void)(); h.render(); await h.settle(); + assert.equal(h.resolutions.length, 2); + assert.equal(h.workspace()?.notificationDestination?.notificationId, ID); + await h.workspace()?.onNotificationDestinationAccepted?.(ID); await h.settle(); + assert.equal(attempts, 2); + assert.equal(h.find('Notice'), undefined); + assert.equal(h.clearedNativeResponses, 1); + assert.equal(h.workspace()?.notificationDestination, undefined); + h.click(); await h.settle(); + assert.equal(h.resolutions.length, 2); + h.unmount(); +}); + +it('shows a retry when persisting OPENED acceptance fails and does not strand the click lease', async () => { + const base = memoryStorage(); + let fail = true; + const storage = { ...base, setItem: async (key: string, value: string) => { + if (fail && (JSON.parse(value) as { accepted: string[] }).accepted.length > 0) throw new Error('storage unavailable'); + await base.setItem(key, value); + } }; + const h = appRootHarness({ storage }); + h.render(); await h.settle(); await h.login(); + h.click(); await h.settle(); + await h.workspace()?.onNotificationDestinationAccepted?.(ID); await h.settle(); + const notice = h.find('Notice'); assert.ok(notice); + assert.ok(notice.props.onRetry); + assert.equal(h.clearedNativeResponses, 0); + assert.match(storage.snapshot()[0]!, /pending.*N06/u); + fail = false; + (notice.props.onRetry as () => void)(); h.render(); await h.settle(); + assert.equal(h.resolutions.length, 2); + await h.workspace()?.onNotificationDestinationAccepted?.(ID); await h.settle(); + assert.equal(h.find('Notice'), undefined); + assert.equal(h.clearedNativeResponses, 1); + h.unmount(); +}); + + +it('keeps account-B destination while account-A N03 acknowledgement finishes late', async () => { + const oldAck = deferred(); + const idB = '31200000-0000-4000-8000-000000000003'; + const clickB = { ...click, notificationId: idB }; + const h = appRootHarness({ + coldClick: { ...click, kind: 'N03' }, + acknowledge: async (token) => { if (token === 'account-A-access') await oldAck.promise; }, + resolver: async (token, id) => token === 'account-A-access' + ? { notificationId: id, destination: { type: 'ASSIGNMENT_RELEASED' } } + : { notificationId: id, destination: { type: 'EXECUTION', executionContextId: 'context-B', routePlanId: 'route-B', targetStopId: 'delivery-stop-B' } }, + }); + h.render(); await h.settle(); await h.login(); + const notice = h.find('Notice'); assert.ok(notice); + (notice.props.onClose as () => void)(); await h.settle(); + assert.equal(h.acknowledgements.length, 1); + await h.login('account-B'); h.click(clickB); await h.settle(); + assert.equal(h.workspace()?.notificationDestination?.routePlanId, 'route-B'); + oldAck.resolve(); await h.settle(); + assert.equal(h.workspace()?.notificationDestination?.notificationId, idB); + await h.workspace()?.onNotificationDestinationAccepted?.(idB); await h.settle(); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.deepEqual(h.acknowledgements.map(({ token, id }) => ({ token, id })), [ + { token: 'account-A-access', id: ID }, { token: 'account-B-access', id: idB }, + ]); + h.unmount(); +}); + +it('does not restore account-A when its refresh completes after a manual account-B login', async () => { + const oldRefresh = deferred(); + const h = appRootHarness({ refresh: async () => oldRefresh.promise }); + h.render(); await h.settle(); await h.login(); + h.fireAuthRefresh(); await h.settle(); + assert.equal(h.refreshRequests, 1); + await h.login('account-B'); + oldRefresh.resolve(auth()); await h.settle(); + assert.equal(h.workspace()?.authSession.account.id, 'account-B'); + assert.deepEqual(h.savedSessions.map((session) => session.account.id), ['account-A', 'account-B']); + h.unmount(); +}); + +it('does not clear account-B destination when account-A native-response cleanup finishes late', async () => { + const oldClear = deferred(); + let clears = 0; + const idB = '31200000-0000-4000-8000-000000000003'; + const h = appRootHarness({ + clearNativeResponse: async () => { if (++clears === 1) await oldClear.promise; }, + resolver: async (token, id) => ({ notificationId: id, destination: { + type: 'EXECUTION', executionContextId: token === 'account-B-access' ? 'context-B' : 'context-A', + routePlanId: token === 'account-B-access' ? 'route-B' : 'route-A', targetStopId: token === 'account-B-access' ? 'delivery-stop-B' : 'delivery-stop-A', + } }), + }); + h.render(); await h.settle(); await h.login(); h.click(); await h.settle(); + const oldAcceptance = h.workspace()?.onNotificationDestinationAccepted?.(ID); + await h.settle(); assert.equal(clears, 1); + await h.login('account-B'); h.click({ ...click, notificationId: idB }); await h.settle(); + assert.equal(h.workspace()?.notificationDestination?.routePlanId, 'route-B'); + oldClear.resolve(); await oldAcceptance; await h.settle(); + assert.equal(h.workspace()?.notificationDestination?.notificationId, idB); + assert.equal(h.acknowledgements.length, 1); + await h.workspace()?.onNotificationDestinationAccepted?.(idB); await h.settle(); + assert.equal(h.workspace()?.notificationDestination, undefined); + assert.equal(h.acknowledgements.length, 2); + h.unmount(); +}); + +it('closes inbox and release notice with Android back while retaining the workspace', async () => { + const h = appRootHarness({ resolver: async (_token, id) => ({ notificationId: id, destination: { type: 'ASSIGNMENT_RELEASED' } }) }); + h.render(); await h.settle(); await h.login(); + h.pressText('알림함'); h.render(); await h.settle(); + assert.ok(h.find('Inbox')); assert.ok(h.workspace()); + assert.equal(h.hardwareBack(), true); h.render(); await h.settle(); + assert.equal(h.find('Inbox'), undefined); assert.ok(h.workspace()); + h.click({ ...click, kind: 'N03' }); await h.settle(); + assert.ok(h.find('Notice')); + assert.equal(h.hardwareBack(), true); await h.settle(); + assert.equal(h.find('Notice'), undefined); assert.ok(h.workspace()); + assert.equal(h.acknowledgements.length, 1); + h.unmount(); +}); diff --git a/src/app/driverNotificationNavigation.test.ts b/src/app/driverNotificationNavigation.test.ts new file mode 100644 index 0000000..8ec29a0 --- /dev/null +++ b/src/app/driverNotificationNavigation.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; +import { resolveDriverNotificationClick } from './driverNotificationNavigation'; + +const id = '10000000-0000-4000-8000-000000000001'; +const click = { kind: 'N06' as const, notificationId: id, schemaVersion: '1' as const, expiresAt: '2099-10-07T01:00:00Z', status: 'current' as const }; +it('clicks resolve only a current server destination and send zero business commands', async () => { + const calls: string[] = []; + const result = await resolveDriverNotificationClick(click, 'account-token', async (token, notificationId) => { + calls.push(`GET:${token}:${notificationId}`); + return { notificationId: id, destination: { type: 'EXECUTION', executionContextId: id, routePlanId: id, targetStopId: id } }; + }); + assert.deepEqual(calls, [`GET:account-token:${id}`]); + assert.equal(result.kind, 'destination'); + if (result.kind === 'destination') assert.equal(result.destination.targetStopId, id); +}); +it('N03 reveals only release even if a resolver incorrectly returns route details', async () => { + const result = await resolveDriverNotificationClick({ ...click, kind: 'N03' }, 'token', async () => ({ + notificationId: id, destination: { type: 'EXECUTION', executionContextId: id, routePlanId: id }, + })); + assert.equal(result.kind, 'notice'); + assert.ok(!JSON.stringify(result).includes('routePlanId')); +}); +it('expired and unsupported clicks never request delivery details', async () => { + let calls = 0; + const resolve = async () => { calls += 1; throw new Error('unexpected'); }; + assert.equal((await resolveDriverNotificationClick({ ...click, status: 'expired' }, 'token', resolve)).kind, 'notice'); + assert.equal((await resolveDriverNotificationClick({ kind: 'unsupported', notificationId: id, reason: 'UNSUPPORTED_PAYLOAD' }, 'token', resolve)).kind, 'notice'); + assert.equal(calls, 0); +}); +it('legacy route and handoff clicks retain refresh behavior', async () => { + for (const notification of [{ kind: 'route_changed' as const, notificationId: id }, { kind: 'bundle_handoff' as const, notificationId: id, handoffRequestId: id }]) { + assert.equal((await resolveDriverNotificationClick(notification, 'token', async () => { throw new Error('unexpected'); })).kind, 'refresh'); + } +}); diff --git a/src/app/driverNotificationNavigation.ts b/src/app/driverNotificationNavigation.ts new file mode 100644 index 0000000..9f09171 --- /dev/null +++ b/src/app/driverNotificationNavigation.ts @@ -0,0 +1,32 @@ +import { resolveDriverOperationalNotification, type DriverNotificationResolution } from '../api/dsvDriverOperational'; +import type { DriverNotificationClick } from '../domain/notifications/driverPushNotification'; + +export type DriverNotificationDestination = { + notificationId: string; + executionContextId: string; + routePlanId: string; + targetStopId?: string; +}; +export type DriverNotificationNavigation = + | { kind: 'destination'; destination: DriverNotificationDestination } + | { kind: 'notice'; message: string } + | { kind: 'refresh' }; + +// Payloads supply notification identity only. The authenticated resolver owns destinations. +export async function resolveDriverNotificationClick( + click: DriverNotificationClick, + accessToken: string, + resolve: (token: string, id: string) => Promise = resolveDriverOperationalNotification, +): Promise { + if (click.kind === 'unsupported') return { kind: 'notice', message: '이 알림 형식은 지원하지 않습니다. 앱 버전과 알림함을 확인해 주세요.' }; + if (click.kind === 'route_changed' || click.kind === 'bundle_handoff') return { kind: 'refresh' }; + if (click.status === 'expired' || Date.parse(click.expiresAt) <= Date.now()) { + return { kind: 'notice', message: '이 알림은 만료되었습니다. 최신 알림함을 확인해 주세요.' }; + } + const resolution = await resolve(accessToken, click.notificationId); + if (resolution.notificationId !== click.notificationId) throw new Error('NOTIFICATION_ID_MISMATCH'); + if (click.kind === 'N03' || resolution.destination.type === 'ASSIGNMENT_RELEASED') { + return { kind: 'notice', message: '이 배차의 배정이 해제되었습니다.' }; + } + return { kind: 'destination', destination: { notificationId: click.notificationId, ...resolution.destination } }; +} diff --git a/src/app/driverOperationalFlow.test.ts b/src/app/driverOperationalFlow.test.ts new file mode 100644 index 0000000..a9ff45d --- /dev/null +++ b/src/app/driverOperationalFlow.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; + +import { loadDriverExecutionContexts, loadDriverOperationalInbox, startDriverExecution } from '../api/dsvDriverOperational'; +import { DriverCommandQueue, type DriverQueuedCommand } from '../domain/delivery/driverCommandQueue'; +import { createDriverNotificationRecovery } from '../domain/notifications/driverNotificationRecovery'; +import { parseDriverPushNotification } from '../domain/notifications/driverPushNotification'; +import { resolveDriverNotificationClick } from './driverNotificationNavigation'; + +it('connects synthetic publication, warehouse arrival, server T+300 warning, login recovery and explicit atomic approval', async () => { + const uuid = (n: number) => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`; + const context = { executionContextId: uuid(1), routePlanId: uuid(2), routeVersion: 2, assignmentEpoch: '3', assignmentGeneration: '4', expectedRouteVersionId: uuid(3), serviceDate: '2026-10-07', status: 'ACTIVE' as const, startedAt: null as string | null }; + const notifications: { id: string; kind: string; createdAt: string }[] = []; + const postBodies: Record[] = []; + const oldFetch = globalThis.fetch; + const previousBase = process.env.EXPO_PUBLIC_DSV_API_BASE_URL; + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'http://127.0.0.1:43199'; + let loseResponse = true; + globalThis.fetch = async (request, options) => { + const path = new URL(String(request)).pathname; + let data: unknown; + if (path.endsWith('/executions')) data = { items: [context], limit: 100 }; + else if (path.endsWith('/operational-notifications')) data = { items: notifications.map((notification) => ({ + ...notification, ackedAt: null, businessStatus: 'OPEN', expiresAt: '2099-10-07T01:00:00Z', summary: { title: '합성 안내', body: '합성 검증 자료' }, + })), nextCursor: null }; + else if (path.endsWith('/resolve')) data = { notificationId: uuid(6), destination: { type: 'EXECUTION', executionContextId: context.executionContextId, routePlanId: context.routePlanId } }; + else if (path.endsWith('/start')) { + assert.equal(options?.method, 'POST'); + const body = JSON.parse(String(options?.body)); postBodies.push(body); + context.startedAt = body.occurredAt; + if (loseResponse) { loseResponse = false; throw new Error('synthetic connection lost after server commit'); } + data = { assignmentEpoch: context.assignmentEpoch, commandId: body.commandId, duplicate: true, executionContextId: context.executionContextId, pickupCompletedEventId: uuid(8), routeStartedEventId: uuid(9), routeVersion: context.routeVersion }; + } else throw new Error(`Unexpected endpoint ${path}`); + return new Response(JSON.stringify({ data, error: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + try { + // These intents are synthetic SERVER progression. The client creates no geofence or reminder timer. + notifications.push({ id: uuid(4), kind: 'N01', createdAt: '2026-10-07T00:00:00Z' }); + assert.equal((await loadDriverOperationalInbox('synthetic-account-token')).items[0]?.kind, 'N01'); + notifications.push({ id: uuid(5), kind: 'N04', createdAt: '2026-10-07T00:10:00Z' }); + const departure = Date.parse('2026-10-07T00:15:00Z'); + notifications.push({ id: uuid(6), kind: 'N05', createdAt: new Date(departure + 300_000).toISOString() }); + const warning = (await loadDriverOperationalInbox('synthetic-account-token')).items.find((item) => item.kind === 'N05')!; + assert.equal(Date.parse(warning.createdAt) - departure, 300_000); + const click = parseDriverPushNotification('native-response', { schemaVersion: '1', kind: 'N05', notificationId: warning.id, expiresAt: warning.expiresAt }); + assert.ok(click); + const saved = new Map(); + const storage = { getItem: async (key: string) => saved.get(key) ?? null, setItem: async (key: string, value: string) => { saved.set(key, value); }, removeItem: async (key: string) => { saved.delete(key); } }; + const coldStart = createDriverNotificationRecovery(storage); + await coldStart.receiveClick(click); + assert.equal(coldStart.acquirePending(), null); + const restarted = createDriverNotificationRecovery(storage); + await restarted.restore(); await restarted.setAccount(uuid(7)); + const lease = restarted.acquirePending()!; + const destination = await resolveDriverNotificationClick(lease.notification, 'synthetic-restored-token'); + assert.equal(destination.kind, 'destination'); + const contexts = await loadDriverExecutionContexts('synthetic-restored-token'); + assert.equal(contexts[0]?.executionContextId, context.executionContextId); + assert.equal(postBodies.length, 0, 'publication/inbox/click/login/navigation reads issue zero business POSTs'); + let persisted: DriverQueuedCommand[] = []; + const queueOptions = { + store: { load: async () => structuredClone(persisted), save: async (commands: readonly DriverQueuedCommand[]) => { persisted = structuredClone([...commands]); } }, + getSession: () => ({ accountId: uuid(7), generation: 1 }), + loadContexts: () => loadDriverExecutionContexts('synthetic-restored-token'), + createCommandId: () => uuid(10), now: () => '2026-10-07T00:21:00Z', + send: (command: DriverQueuedCommand) => startDriverExecution('synthetic-restored-token', command.executionContextId, command.payload), + }; + const queue = new DriverCommandQueue(queueOptions); + // Only this explicit user action creates the start command. + const first = await queue.enqueueStart(contexts[0]!); + assert.equal(first.status, 'pending', 'lost ACK is never shown as success'); + const afterRestart = new DriverCommandQueue(queueOptions); + await afterRestart.retryPending(); + assert.equal(afterRestart.listForAccount(uuid(7))[0]?.status, 'confirmed'); + assert.equal(postBodies.length, 2); + assert.deepEqual(postBodies[0], postBodies[1], 'retry/restart preserves commandId, occurredAt and every fence'); + assert.equal(postBodies[1]?.commandId, uuid(10)); + await restarted.accept(lease); + } finally { + globalThis.fetch = oldFetch; + if (previousBase === undefined) delete process.env.EXPO_PUBLIC_DSV_API_BASE_URL; + else process.env.EXPO_PUBLIC_DSV_API_BASE_URL = previousBase; + } +}); diff --git a/src/app/driverPushNotificationWiring.test.ts b/src/app/driverPushNotificationWiring.test.ts index 3dfdfcc..6ac503d 100644 --- a/src/app/driverPushNotificationWiring.test.ts +++ b/src/app/driverPushNotificationWiring.test.ts @@ -15,7 +15,10 @@ describe('Driver push notification lifecycle wiring', () => { assert.match(source, /revokeExpoDriverPushNotifications/u); assert.match(source, /notificationRefreshKey/u); assert.match(source, /refreshRequestKey=\{notificationRefreshKey\}/u); - assert.match(source, /await revokeExpoDriverPushNotifications/u); + const logoutStart = source.indexOf('const logout ='); + const logout = source.slice(logoutStart, source.indexOf('\n useEffect(', logoutStart)); + assert.ok(logout.indexOf('revokeExpoDriverPushNotifications(') < logout.indexOf('await discardAuthSession()')); + assert.match(logout, /await revoke;/u); }); it('makes a notification refresh reload the authenticated route', () => { @@ -41,3 +44,42 @@ describe('Driver push notification lifecycle wiring', () => { ); }); }); + + +const servicePath = join(appDirectory, '../platform/expo/notifications/expoDriverNotificationService.ts'); + +describe('Authenticated notification destination boundaries', () => { + it('receives cold-start clicks before login without a handled marker', () => { + const source = readFileSync(join(appDirectory, 'AppRoot.tsx'), 'utf8'); + assert.match(source, /notificationRecovery\.restore\(\)/u); + assert.match(source, /subscribeToExpoDriverNotificationClicks/u); + assert.match(source, /notificationRecovery\.receiveClick/u); + assert.match(source, /notificationRecovery\.setAccount\(session\.account\.id\)/u); + const platform = readFileSync(servicePath, 'utf8'); + assert.match(platform, /addNotificationResponseReceivedListener/u); + assert.match(platform, /getLastNotificationResponseAsync/u); + assert.doesNotMatch(platform, /LAST_HANDLED_NOTIFICATION|last-handled-notification/u); + }); + + it('keeps notification receipts separate from click resolution and token capability', () => { + const platform = readFileSync(servicePath, 'utf8'); + const receiptFunction = platform.slice(platform.indexOf('export function subscribeToExpoDriverPushNotifications'), platform.indexOf('function registerToken')); + assert.match(receiptFunction, /addNotificationReceivedListener/u); + assert.match(receiptFunction, /addPushTokenListener/u); + assert.doesNotMatch(receiptFunction, /addNotificationResponseReceivedListener|getLastNotificationResponseAsync/u); + assert.match(platform, /registerDriverPushTokenWithCapability/u); + assert.match(platform, /operationalEnabled: DRIVER_OPERATIONAL_ENABLED/u); + assert.match(platform, /deviceId: Application\.getAndroidId\(\)/u); + assert.match(platform, /ROUTE_UPDATES_CHANNEL_ID = 'route-updates'/u); + assert.doesNotMatch(platform, /getExpoPushTokenAsync|scheduleNotificationAsync/u); + }); + + it('holds the click until authenticated destination acceptance and clears logout state', () => { + const source = readFileSync(join(appDirectory, 'AppRoot.tsx'), 'utf8'); + assert.match(source, /notificationRecovery\.acquirePending\(\)/u); + assert.match(source, /notificationRecovery\.isCurrent\(lease\)/u); + assert.match(source, /onNotificationDestinationAccepted/u); + assert.match(source, /notificationRecovery\.clearForLogout\(\)/u); + assert.match(source, /clearExpoDriverNotificationResponse/u); + }); +}); diff --git a/src/app/driverWorkspaceReads.test.ts b/src/app/driverWorkspaceReads.test.ts new file mode 100644 index 0000000..5b4f69e --- /dev/null +++ b/src/app/driverWorkspaceReads.test.ts @@ -0,0 +1,425 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; + +import type { DriverAuthSession } from '../api/dsvDriverAuth'; +import * as events from '../api/dsvDriverEvents'; +import type { DriverDeliveryRoute } from '../api/dsvDriverRoute'; +import * as plan from '../domain/delivery/deliveryPlan'; +import * as commandQueue from '../domain/delivery/driverCommandQueue'; +import { DriverOperationalApiError, type DriverExecutionContext } from '../api/dsvDriverOperational'; + +type Props = Parameters[0]; +type Element = { type: unknown; props: Record }; +type Execution = Parameters[0]; +const flush = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { resolve = accept; }); + return { promise, resolve }; +} +const session: DriverAuthSession = { + account: { id: '77777777-7777-4777-8777-777777777777', loginId: 'driver', name: 'Driver', phone: '+821012345678', connectionStatus: 'LINKED', linkedDrivers: [] }, + accessToken: 'account-token', expiresAt: '2026-10-07T10:00:00Z', refreshToken: 'refresh', + refreshTokenExpiresAt: '2026-11-07T10:00:00Z', tokenType: 'Bearer', ttlSeconds: 3600, use: 'dsv_driver_account', +}; +const route: DriverDeliveryRoute = { + availableRoutes: [], deliveryDate: '2026-10-07', depotCoordinate: null, destinationNotesById: {}, + etaStatus: 'READY', executionStatus: 'IN_PROGRESS', nextDeliveryStopId: null, + orders: [{ ...plan.PREVIEW_DELIVERY_ORDERS[0]!, id: 'target-stop', status: 'DELIVERED' }], + pickupCompletedAt: '2026-10-07T00:00:00Z', routeAccessToken: 'route-token', routeContext: 'route-A', + routeId: 'route-A', routeName: 'Route A', routePlanId: 'route-A', routeVersionId: 'child-A', serverRouteGeometry: null, timezone: 'Asia/Seoul', +}; + +// Execute production effects and handlers. Native layout and FCM require separate device evidence. +export function workspaceHarness(overrides: { + loadedRoute?: DriverDeliveryRoute; + routeApi?: Record; + operationalApi?: Record; + commands?: unknown; + storedCommands?: { current: commandQueue.DriverQueuedCommand[] }; + storage?: Map; +} = {}) { + const slots: unknown[] = []; + type Effect = { deps: unknown[]; cleanup?: void | (() => void) }; + const effects = new Map(); + const pending: (() => void)[] = []; + let index = 0; + let dirty = false; + let active = true; + let execution!: Execution; + let tree: unknown; + let lateWrites = 0; + let uuidCounter = 0; + const storage = overrides.storage ?? new Map(); + const storedCommands = overrides.storedCommands ?? { current: [] }; + const loadedRoute = overrides.loadedRoute ?? route; + const choice = { deliveryDate: loadedRoute.deliveryDate, executionStatus: loadedRoute.executionStatus, + routeAccessToken: loadedRoute.routeAccessToken, routeContext: loadedRoute.routeContext, + routeName: loadedRoute.routeName, routePlanId: loadedRoute.routePlanId }; + const same = (a: unknown[] | undefined, b: unknown[]) => a?.length === b.length && a.every((v, i) => Object.is(v, b[i])); + const jsx = (type: unknown, props: Record) => ({ type, props }); + const dependencies: Record = { + react: { + useRef: (initial: unknown) => { const i = index++; return slots[i] ?? (slots[i] = { current: initial }); }, + useState: (initial: unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = typeof initial === 'function' ? initial() : initial; + return [slots[i], (value: unknown) => { if (!active) lateWrites += 1; + const next = typeof value === 'function' ? value(slots[i]) : value; + if (!Object.is(next, slots[i])) { slots[i] = next; dirty = true; } + }]; + }, + useEffect: (setup: () => void | (() => void), deps: unknown[]) => { + const i = index++; const old = effects.get(i); + if (!same(old?.deps, deps)) pending.push(() => { old?.cleanup?.(); effects.set(i, { deps, cleanup: setup() }); }); + }, + }, + 'react/jsx-runtime': { jsx, jsxs: jsx, Fragment: 'Fragment' }, + 'react-native': { Platform: { OS: 'web' }, StyleSheet: { create: (styles: unknown) => styles }, + View: 'View', Text: 'Text', Pressable: 'Pressable', ScrollView: 'ScrollView' }, + 'react-native-safe-area-context': { useSafeAreaInsets: () => ({ bottom: 0 }) }, + 'expo-symbols': {}, + 'expo-modules-core': { uuid: { v4: () => `11111111-1111-4111-8111-${String(++uuidCounter).padStart(12, '0')}` } }, + '@react-native-async-storage/async-storage': { __esModule: true, default: { + getItem: async (key: string) => storage.get(key) ?? null, + setItem: async (key: string, value: string) => { storage.set(key, value); }, + removeItem: async (key: string) => { storage.delete(key); }, + } }, + '../../api/dsvDriverEvents': events, + '../../api/dsvDriverProofMedia': {}, + '../../api/dsvDriverRoute': { + DriverRouteApiError: class extends Error {}, loadDriverDeliveryRouteChoices: async () => [choice], + loadDriverCompletedRouteHistory: async () => [], loadDriverDeliveryRoute: async () => loadedRoute, + ...overrides.routeApi, + }, + '../../api/dsvDriverOperational': { DriverOperationalApiError, DRIVER_OPERATIONAL_ENABLED: false, ...overrides.operationalApi }, + '../../config/driverOperational': { DRIVER_OPERATIONAL_ENABLED: overrides.operationalApi?.DRIVER_OPERATIONAL_ENABLED ?? false }, + '../../domain/delivery/deliveryPlan': plan, + '../../domain/navigation/androidBackNavigation': {}, + '../../domain/delivery/driverCommandQueue': overrides.commands ?? commandQueue, + '../../platform/expo/storage/driverCommandStore': { createDriverCommandStore: () => ({ + load: async () => storedCommands.current, + save: async (commands: commandQueue.DriverQueuedCommand[]) => { storedCommands.current = commands; }, + }) }, + './DeliveryScreen': { DeliveryScreen: 'DeliveryScreen' }, + './DeliveryMapScreen': { DeliveryMapScreen: 'DeliveryMapScreen' }, + './DriverDeliveryException': { DriverDeliveryException: 'DriverDeliveryException' }, + './AppDialog': { useAppDialog: () => ({ dialog: null, showDialog: () => undefined }) }, + './DeliveryExecutionActions': { DeliveryExecutionOverlay: 'Overlay', useDeliveryExecution: (options: Execution) => { + execution = options; return { isLocked: false }; + } }, + './DriverRefreshControl': {}, './DriverSettingsModal': {}, './DeliverySpaceScreen': {}, + }; + const source = readFileSync(new URL('../ui/driver/DriverWorkspace.tsx', import.meta.url), 'utf8'); + const { outputText } = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, + } }); + const module = { exports: {} }; + runInNewContext(outputText, { module, exports: module.exports, Error, Date, Intl, Map, Set, Promise, process, + require: (name: string) => { assert.ok(name in dependencies, `Unexpected dependency: ${name}`); return dependencies[name]; } }); + const component = (module.exports as typeof import('../ui/driver/DriverWorkspace')).DriverWorkspace; + let props: Props = { authSession: session, onLogout: () => undefined, refreshRequestKey: 0 }; + function render(next: Partial = {}) { + props = { ...props, ...next }; index = 0; dirty = false; tree = component(props); + pending.splice(0).forEach((effect) => effect()); return tree; + } + function nodes(value: unknown, result: Element[] = []): Element[] { + if (Array.isArray(value)) value.forEach((child) => nodes(child, result)); + else if (value !== null && typeof value === 'object' && 'props' in value) { + const element = value as Element; result.push(element); nodes(element.props.children, result); + } + return result; + } + return { + render, + async settle() { for (let i = 0; i < 8; i++) { await flush(); if (dirty) render(); } }, + find(predicate: (element: Element) => boolean) { return nodes(tree).find(predicate); }, + select(id = loadedRoute.routePlanId) { const selector = nodes(tree).find((element) => 'onSelect' in element.props); + assert.ok(selector); (selector.props.onSelect as (id: string) => void)(id); render(); }, + get execution() { return execution; }, + get lateWrites() { return lateWrites; }, + unmount() { active = false; for (const effect of effects.values()) effect.cleanup?.(); }, + }; +} + +describe('Driver workspace read and explicit command boundaries', () => { + it('sends zero business POSTs during list, detail, refresh and tab navigation with terminal stops', async () => { + const requests: RequestInit[] = []; + globalThis.fetch = async (_url, init) => { requests.push(init ?? {}); return new Response(JSON.stringify({ data: { eventId: 'event' } })); }; + const h = workspaceHarness(); h.render(); await h.settle(); h.select(); await h.settle(); + assert.equal(h.execution.summary, null); + const screen = h.find((element) => element.type === 'DeliveryScreen'); assert.ok(screen); + (screen.props.onRefresh as () => void)(); h.render(); await h.settle(); + const map = h.find((element) => element.props.label === '지도'); assert.ok(map); + (map.props.onPress as () => void)(); h.render(); await h.settle(); + assert.equal(requests.filter(({ method }) => method === 'POST').length, 0); + h.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('offers explicit completion recovery and reuses the original event identity after response loss', async () => { + const bodies: Record[] = []; + globalThis.fetch = async (_url, init) => { + bodies.push(JSON.parse(init?.body as string)); + if (bodies.length === 1) throw new Error('response lost'); + return new Response(JSON.stringify({ data: { eventId: 'event' } })); + }; + const h = workspaceHarness(); h.render(); await h.settle(); h.select(); await h.settle(); + const button = h.find((element) => element.props.accessibilityLabel === '배차 완료 복구'); assert.ok(button); + await assert.rejects(h.execution.onCompleteRoute(), /response lost/u); + await h.execution.onCompleteRoute(); h.render(); await h.settle(); + assert.equal(bodies.length, 2); assert.equal(bodies[0]!.eventType, 'ROUTE_COMPLETED'); + assert.equal(bodies[0]!.clientEventId, bodies[1]!.clientEventId); + assert.equal(bodies[0]!.occurredAt, bodies[1]!.occurredAt); + h.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('restores explicit completion identity after restart without sending completion from reads', async () => { + const storage = new Map(); + const bodies: Record[] = []; + globalThis.fetch = async (_url, init) => { bodies.push(JSON.parse(init?.body as string)); + if (bodies.length === 1) throw new Error('response lost'); + return new Response(JSON.stringify({ data: { eventId: 'event' } })); + }; + const initial = workspaceHarness({ storage }); initial.render(); await initial.settle(); initial.select(); await initial.settle(); + await assert.rejects(initial.execution.onCompleteRoute()); initial.unmount(); + const restarted = workspaceHarness({ storage }); restarted.render(); await restarted.settle(); restarted.select(); await restarted.settle(); + assert.equal(bodies.length, 1); + await restarted.execution.onCompleteRoute(); + assert.deepEqual(bodies[0], bodies[1]); assert.equal(storage.size, 0); + restarted.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); +}); + +const operationalRoute: DriverDeliveryRoute = { + ...route, routePlanId: '22222222-2222-4222-8222-222222222222', routeId: '22222222-2222-4222-8222-222222222222', + routeVersionId: '33333333-3333-4333-8333-333333333333', executionStatus: 'READY', etaStatus: 'PRE_PICKUP', pickupCompletedAt: null, + nextDeliveryStopId: '44444444-4444-4444-8444-444444444444', + orders: [ + { ...route.orders[0]!, id: '44444444-4444-4444-8444-444444444444', status: 'PENDING', destinationId: 'first', destinationName: 'Server next stop' }, + { ...route.orders[0]!, id: '55555555-5555-4555-8555-555555555555', status: 'PENDING', destinationId: 'target', destinationName: 'Notification target' }, + ], +}; +const context: DriverExecutionContext = { + executionContextId: '66666666-6666-4666-8666-666666666666', routePlanId: operationalRoute.routePlanId, + routeVersion: 1, assignmentEpoch: '1', assignmentGeneration: '1', expectedRouteVersionId: operationalRoute.routeVersionId!, + serviceDate: operationalRoute.deliveryDate, status: 'ACTIVE', startedAt: null, +}; + +describe('Driver workspace operational commands and exact notification target', () => { + it('opens the N06 target deliveryStopId instead of the server next stop, and retains it after OPENED consumption', async () => { + let accepted = 0; let rejected = 0; let posts = 0; + globalThis.fetch = async () => { posts += 1; throw new Error('Unexpected business request'); }; + const h = workspaceHarness({ loadedRoute: operationalRoute, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + } }); + h.render({ notificationDestination: { notificationId: 'notification', executionContextId: context.executionContextId, + routePlanId: context.routePlanId, targetStopId: operationalRoute.orders[1]!.id }, + onNotificationDestinationAccepted: () => { accepted += 1; }, onNotificationDestinationRejected: () => { rejected += 1; } }); + await h.settle(); + assert.equal(accepted, 1); assert.equal(rejected, 0); + assert.equal(h.execution.summary?.deliveryStopId, operationalRoute.orders[1]!.id); + h.render({ notificationDestination: undefined }); await h.settle(); + assert.equal(h.execution.summary?.destinationName, 'Notification target'); + assert.equal(h.find((element) => element.type === 'DeliveryScreen')?.props.nextDeliveryStopId, operationalRoute.orders[1]!.id); + assert.equal(posts, 0); h.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('rejects completed or absent N06 targets and mismatched child fences without opening another stop', async () => { + for (const targetStopId of ['missing', operationalRoute.orders[0]!.id]) { + let accepted = 0; let rejected = 0; + const h = workspaceHarness({ loadedRoute: { ...operationalRoute, orders: operationalRoute.orders.map((order, index) => + index === 0 ? { ...order, status: 'DELIVERED' } : order) }, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + } }); + h.render({ notificationDestination: { notificationId: targetStopId, executionContextId: context.executionContextId, + routePlanId: context.routePlanId, targetStopId }, onNotificationDestinationAccepted: () => { accepted += 1; }, + onNotificationDestinationRejected: () => { rejected += 1; } }); await h.settle(); + assert.equal(accepted, 0); assert.ok(rejected > 0); + assert.equal(h.find((element) => element.type === 'DeliveryScreen'), undefined); h.unmount(); + } + }); + + it('waits for server ACK after a lost atomic start response and never falls back to legacy POSTs', async () => { + const payloads: commandQueue.DriverCommandPayload[] = []; let legacyPosts = 0; + const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + globalThis.fetch = async () => { legacyPosts += 1; throw new Error('Legacy fallback'); }; + const h = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + startDriverExecution: async (_token: string, id: string, payload: commandQueue.DriverCommandPayload) => { + payloads.push(payload); if (payloads.length === 1) throw new Error('response lost'); + return { ...payload, executionContextId: id }; + }, + } }); + h.render(); await h.settle(); h.select(); await h.settle(); + await assert.rejects(h.execution.onStartDelivery(), /서버 승인/u); h.render(); await h.settle(); + assert.equal(stored.current[0]?.status, 'pending'); + await h.execution.onStartDelivery(); h.render(); await h.settle(); + assert.equal(stored.current[0]?.status, 'confirmed'); assert.equal(payloads.length, 2); + assert.equal(payloads[0]!.commandId, payloads[1]!.commandId); assert.equal(payloads[0]!.occurredAt, payloads[1]!.occurredAt); + assert.equal(legacyPosts, 0); h.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('reports an injected reason to the exact stop without any completion event', async () => { + const reports: (commandQueue.DriverCommandPayload & commandQueue.DriverDeliveryExceptionDetails)[] = []; + let legacyPosts = 0; + globalThis.fetch = async () => { legacyPosts += 1; throw new Error('Unexpected event'); }; + const h = workspaceHarness({ loadedRoute: operationalRoute, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + reportDriverDeliveryException: async (_token: string, id: string, + payload: commandQueue.DriverCommandPayload & commandQueue.DriverDeliveryExceptionDetails) => { + reports.push(payload); return { ...payload, executionContextId: id }; + }, + } }); + h.render({ deliveryExceptionReasons: [{ code: 'SYNTHETIC_ONLY', label: '합성 사유', requiresExplanation: true }] }); + await h.settle(); h.select(); await h.settle(); + const form = h.find((element) => element.type === 'DriverDeliveryException'); assert.ok(form); + const submit = form.props.onSubmit as (reason: string, explanation?: string) => Promise; + await assert.rejects(submit('SYNTHETIC_ONLY'), /필수/u); + await submit('SYNTHETIC_ONLY', '합성 흐름 확인'); + assert.equal(reports.length, 1); assert.equal(reports[0]!.targetStopId, operationalRoute.nextDeliveryStopId); + assert.equal(legacyPosts, 0); h.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('replays an offline user start after app restart with the same identity, even when the server already started it', async () => { + const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + const storage = new Map(); + const payloads: commandQueue.DriverCommandPayload[] = []; + const initial = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, storage, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + startDriverExecution: async (_token: string, _id: string, payload: commandQueue.DriverCommandPayload) => { + payloads.push(payload); throw new Error('offline after server commit'); + }, + } }); + initial.render(); await initial.settle(); initial.select(); await initial.settle(); + await assert.rejects(initial.execution.onStartDelivery(), /서버 승인/u); initial.unmount(); await flush(); + assert.equal(stored.current[0]?.status, 'pending'); + const restarted = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, storage, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [{ ...context, startedAt: '2026-10-07T01:00:00.000Z' }], + startDriverExecution: async (_token: string, id: string, payload: commandQueue.DriverCommandPayload) => { + payloads.push(payload); return { ...payload, executionContextId: id }; + }, + } }); + restarted.render(); await restarted.settle(); + assert.equal(stored.current[0]?.status, 'confirmed'); assert.equal(payloads.length, 2); + assert.equal(payloads[0]!.commandId, payloads[1]!.commandId); assert.equal(payloads[0]!.occurredAt, payloads[1]!.occurredAt); + assert.equal(initial.lateWrites, 0); restarted.unmount(); + }); + + it('never uses legacy start after the atomic route disappears on restart', async () => { + const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + const storage = new Map(); let legacy = 0; + globalThis.fetch = async () => { legacy += 1; throw new Error('Forbidden fallback'); }; + const first = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, storage, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + startDriverExecution: async () => { throw new Error('lost response'); }, + } }); + first.render(); await first.settle(); first.select(); await first.settle(); + await assert.rejects(first.execution.onStartDelivery()); first.unmount(); + const restarted = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, storage, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [], + } }); + restarted.render(); await restarted.settle(); restarted.select(); await restarted.settle(); + await assert.rejects(restarted.execution.onStartDelivery(), /기존 시작 명령/u); + assert.equal(legacy, 0); restarted.unmount(); delete (globalThis as { fetch?: unknown }).fetch; + }); + + it('revalidates a consumed N06 target after refresh and prevents completion of another stop', async () => { + let current = operationalRoute; + const h = workspaceHarness({ loadedRoute: operationalRoute, routeApi: { loadDriverDeliveryRoute: async () => current }, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], + } }); + h.render({ notificationDestination: { notificationId: 'notification', executionContextId: context.executionContextId, + routePlanId: context.routePlanId, targetStopId: operationalRoute.orders[1]!.id } }); await h.settle(); + h.render({ notificationDestination: undefined }); await h.settle(); + current = { ...operationalRoute, orders: operationalRoute.orders.map((order, i) => i === 1 ? { ...order, status: 'DELIVERED' } : order) }; + const refresh = h.find((element) => element.type === 'DeliveryScreen')?.props.onRefresh as () => void; + refresh(); h.render(); await h.settle(); + assert.equal(h.find((element) => element.type === 'DeliveryScreen'), undefined); + assert.equal(h.execution.summary, null); h.unmount(); + }); + + it('persists an offline start and report before fresh authority lookup, then retries the original fences after restart', async () => { + for (const kind of ['START_EXECUTION', 'REPORT_DELIVERY_EXCEPTION'] as const) { + let offline = false; let sends = 0; + const storage = new Map(); const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + const h = workspaceHarness({ loadedRoute: operationalRoute, storage, storedCommands: stored, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => { if (offline) throw new Error('offline'); return [context]; }, + startDriverExecution: async () => { sends += 1; throw new Error('must not send offline'); }, + reportDriverDeliveryException: async () => { sends += 1; throw new Error('must not send offline'); }, + } }); + h.render({ deliveryExceptionReasons: [{ code: 'SYNTHETIC_ONLY', label: '합성 사유' }] }); await h.settle(); h.select(); await h.settle(); + offline = true; + if (kind === 'START_EXECUTION') await assert.rejects(h.execution.onStartDelivery(), /서버 승인/u); + else { + const form = h.find((element) => element.type === 'DriverDeliveryException'); assert.ok(form); + await assert.rejects((form.props.onSubmit as (code: string) => Promise)('SYNTHETIC_ONLY'), /서버 승인/u); + } + assert.equal(stored.current.length, 1); assert.equal(stored.current[0]!.type, kind); + assert.equal(stored.current[0]!.status, 'pending'); assert.equal(sends, 0); + const original = { ...stored.current[0]!.payload }; h.unmount(); + const replayed: commandQueue.DriverQueuedCommand['payload'][] = []; + const send = async (_token: string, id: string, payload: commandQueue.DriverQueuedCommand['payload']) => { + replayed.push(payload); return { ...payload, executionContextId: id }; + }; + const restarted = workspaceHarness({ loadedRoute: operationalRoute, storage, storedCommands: stored, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context], startDriverExecution: send, reportDriverDeliveryException: send, + } }); restarted.render(); await restarted.settle(); + assert.equal(stored.current[0]!.status, 'confirmed'); assert.deepEqual(replayed[0], original); + restarted.unmount(); + } + }); + + it('blocks reassignment before sending the cached user command, rather than upgrading its epoch', async () => { + let changed = false; let sends = 0; + const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + const h = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [changed ? { ...context, assignmentEpoch: '2' } : context], + startDriverExecution: async () => { sends += 1; throw new Error('wrong epoch'); }, + } }); h.render(); await h.settle(); h.select(); await h.settle(); changed = true; + await assert.rejects(h.execution.onStartDelivery(), /차단/u); + assert.equal(sends, 0); assert.equal(stored.current[0]?.payload.assignmentEpoch, '1'); + assert.equal(stored.current[0]?.status, 'blocked'); h.unmount(); + }); + + it('recovers authentication and retries a saved command after token refresh without changing its identity', async () => { + let expired = false; let recoveries = 0; + const stored = { current: [] as commandQueue.DriverQueuedCommand[] }; + const sends: { token: string; payload: commandQueue.DriverCommandPayload }[] = []; + const h = workspaceHarness({ loadedRoute: operationalRoute, storedCommands: stored, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async (token: string) => { + if (expired && token === session.accessToken) throw { status: 401, code: 'TOKEN_EXPIRED' }; + return [context]; + }, + startDriverExecution: async (token: string, id: string, payload: commandQueue.DriverCommandPayload) => { + sends.push({ token, payload }); return { ...payload, executionContextId: id }; + }, + } }); h.render({ onAuthenticationRequired: () => { recoveries += 1; } }); await h.settle(); h.select(); await h.settle(); + expired = true; await assert.rejects(h.execution.onStartDelivery(), /서버 승인/u); + assert.equal(recoveries, 1); assert.equal(stored.current[0]?.status, 'pending'); + const original = { ...stored.current[0]!.payload }; + h.render({ authSession: { ...session, accessToken: 'refreshed-token' } }); await h.settle(); + assert.equal(stored.current[0]?.status, 'confirmed'); assert.equal(sends.length, 1); + assert.equal(sends[0]!.token, 'refreshed-token'); assert.deepEqual(sends[0]!.payload, original); h.unmount(); + }); + + it('keeps the newer notification route when an older GET finishes late and drops GET writes after unmount', async () => { + const old = deferred(); + const nextRoute = { ...operationalRoute, routePlanId: '88888888-8888-4888-8888-888888888888', routeName: 'New route', + orders: operationalRoute.orders.map((order) => ({ ...order, destinationName: 'New route destination' })) }; + const nextContext = { ...context, executionContextId: '99999999-9999-4999-8999-999999999999', routePlanId: nextRoute.routePlanId }; + const accepted: string[] = []; + const h = workspaceHarness({ loadedRoute: operationalRoute, routeApi: { loadDriverDeliveryRoute: async (_token: string, id: string) => + id === operationalRoute.routePlanId ? old.promise : nextRoute }, operationalApi: { + DRIVER_OPERATIONAL_ENABLED: true, loadDriverExecutionContexts: async () => [context, nextContext], + } }); + h.render({ notificationDestination: { notificationId: 'old', executionContextId: context.executionContextId, routePlanId: context.routePlanId }, + onNotificationDestinationAccepted: (id) => { accepted.push(id); } }); await h.settle(); + h.render({ notificationDestination: { notificationId: 'new', executionContextId: nextContext.executionContextId, routePlanId: nextContext.routePlanId } }); await h.settle(); + assert.deepEqual(accepted, ['new']); assert.equal(h.execution.summary?.destinationName, 'New route destination'); + h.unmount(); old.resolve(operationalRoute); await flush(); + assert.equal(h.lateWrites, 0); + }); +}); diff --git a/src/app/internalScreens.test.ts b/src/app/internalScreens.test.ts index 8fc7d22..df6f38e 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -159,7 +159,8 @@ describe('authenticated driver screens', () => { assert.match(routeClient, /executionStatus: DriverRouteExecutionStatus/u); assert.match(source, /completeDriverDeliveryRoute/u); assert.match(source, /completesDeliveryRoute/u); - assert.match(source, /reconcileCompletedRoutes/u); + assert.doesNotMatch(source, /reconcileCompletedRoutes/u); + assert.match(source, /accessibilityLabel="배차 완료 복구"/u); assert.match(source, /executionStatus: 'COMPLETED'/u); assert.match(source, /setRouteGroup\('terminal'\)/u); assert.match( @@ -398,7 +399,7 @@ describe('authenticated driver screens', () => { assert.match(source, /groupOrderCount:[\s\S]*lineHeight: 16/u); assert.match(source, /boxCount:[\s\S]*lineHeight: 16/u); assert.match(source, /key=\{`\$\{group\.key\}:\$\{progressState\}`\}/u); - assert.match(workspace, /nextDeliveryStopId=\{route\.nextDeliveryStopId\}/u); + assert.match(workspace, /nextDeliveryStopId=\{activeDeliveryStopId\}/u); assert.match(source, /destinationGroupEmphasis:[\s\S]*paddingHorizontal: 9/u); assert.match(source, /completedPrimaryText:[\s\S]*color: '#475467'/u); assert.match(source, /paddingBottom: 88/u); @@ -454,7 +455,7 @@ describe('authenticated driver screens', () => { assert.match(workspace, /route\.routeVersionId/u); assert.match(workspace, /serverRouteGeometry: null/u); assert.match(workspace, /setLoadAttempt\(\(attempt\) => attempt \+ 1\)/u); - assert.match(workspace, /if \(!completesRoute\) \{\s+setLoadAttempt/u); + assert.match(workspace, /if \(!completesRoute && mountedRef\.current[\s\S]*?setLoadAttempt/u); assert.doesNotMatch(workspace, /onOrdersChange=\{setOrders\}/u); }); @@ -835,7 +836,7 @@ describe('authenticated driver screens', () => { ); assert.match(workspace, /accessToken=\{authSession\.accessToken\}/u); - assert.match(workspace, /onAccountDeletionRequested=\{onLogout\}/u); + assert.match(workspace, /onAccountDeletionRequested=\{handleLogout\}/u); assert.match(settings, /requestDriverAccountDeletion/u); assert.match(settings, /계정 삭제 요청/u); assert.match(settings, /ACCOUNT_DELETION_ACTIVE_ROUTE/u); diff --git a/src/config/driverOperational.ts b/src/config/driverOperational.ts new file mode 100644 index 0000000..fbfdb1d --- /dev/null +++ b/src/config/driverOperational.ts @@ -0,0 +1,3 @@ +// The additive server contract is an unmerged, undeployed candidate. Enable only +// in a reviewed isolated test environment until the operational gate is cleared. +export const DRIVER_OPERATIONAL_ENABLED = process.env.EXPO_PUBLIC_DSV_OPERATIONAL_ENABLED === 'true'; diff --git a/src/domain/delivery/driverCommandQueue.test.ts b/src/domain/delivery/driverCommandQueue.test.ts new file mode 100644 index 0000000..7eb835e --- /dev/null +++ b/src/domain/delivery/driverCommandQueue.test.ts @@ -0,0 +1,430 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DriverCommandQueue, + DriverCommandQueueError, + parseStoredDriverCommands, + type DriverCommandAcknowledgement, + type DriverCommandContext, + type DriverCommandSession, + type DriverCommandStore, + type DriverQueuedCommand, +} from './driverCommandQueue'; + +const accountId = '11111111-1111-4111-8111-111111111111'; +const otherAccountId = '22222222-2222-4222-8222-222222222222'; +const context: DriverCommandContext = { + assignmentEpoch: '3', + assignmentGeneration: '5', + executionContextId: '33333333-3333-4333-8333-333333333333', + expectedRouteVersionId: '44444444-4444-4444-8444-444444444444', + routeVersion: 2, + startedAt: null, + status: 'ACTIVE', +}; +const report = { + reasonCode: 'SYNTHETIC_REASON', + targetStopId: '55555555-5555-4555-8555-555555555555', +}; + +function memoryStore(): DriverCommandStore & { saved: DriverQueuedCommand[] } { + return { + saved: [], + async load() { return structuredClone(this.saved); }, + async save(commands) { this.saved = structuredClone([...commands]); }, + }; +} + +function acknowledge(command: DriverQueuedCommand): DriverCommandAcknowledgement { + return { + assignmentEpoch: command.payload.assignmentEpoch, + commandId: command.payload.commandId, + executionContextId: command.executionContextId, + routeVersion: command.payload.routeVersion, + }; +} + +function harness(store = memoryStore()) { + let nextId = 0; + const state = { + authenticationRequests: [] as DriverCommandSession[], + contexts: [{ ...context }] as DriverCommandContext[], + failSave: false, + session: { accountId, generation: 1 } as DriverCommandSession | null, + sends: [] as DriverQueuedCommand[], + send: async (command: DriverQueuedCommand) => acknowledge(command), + }; + const makeQueue = () => new DriverCommandQueue({ + createCommandId: () => `66666666-6666-4666-8666-${String(++nextId).padStart(12, '0')}`, + getSession: () => state.session, + loadContexts: async () => state.contexts, + now: () => '2026-10-07T00:00:00.000Z', + onAuthenticationRequired: (session) => { state.authenticationRequests.push(session); }, + send: async (command) => { + assert.ok(store.saved.some((saved) => saved.payload.commandId === command.payload.commandId)); + state.sends.push(structuredClone(command)); + return state.send(command); + }, + store: { + load: () => store.load(), + save: async (commands) => { + if (state.failSave) throw new Error('storage failure'); + await store.save(commands); + }, + }, + }); + return { makeQueue, queue: makeQueue(), state, store }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +describe('durable driver commands', () => { + it('persists before sending and confirms only a matching server acknowledgement', async () => { + const { queue, store, state } = harness(); + const result = await queue.enqueueStart(context); + assert.equal(result.status, 'confirmed'); + assert.equal(state.sends.length, 1); + assert.equal(store.saved[0].status, 'confirmed'); + assert.deepEqual(state.sends[0].payload, { + assignmentEpoch: '3', assignmentGeneration: '5', + commandId: '66666666-6666-4666-8666-000000000001', + expectedRouteVersionId: context.expectedRouteVersionId, + occurredAt: '2026-10-07T00:00:00.000Z', routeVersion: 2, + }); + }); + + it('replays the identical command after response loss, process restart, and an already-started context', async () => { + const { queue, makeQueue, state } = harness(); + state.send = async () => { throw new TypeError('response lost'); }; + const pending = await queue.enqueueStart(context); + assert.equal(pending.status, 'pending'); + state.contexts = [{ ...context, startedAt: '2026-10-07T00:00:00.000Z' }]; + state.send = async (command) => acknowledge(command); + const restarted = makeQueue(); + await restarted.retryPending(); + assert.deepEqual(state.sends[0].payload, state.sends[1].payload); + assert.equal(restarted.listForAccount(accountId)[0].status, 'confirmed'); + }); + + it('keeps offline and server-failure commands pending and deduplicates repeated user start/report input', async () => { + const { queue, state } = harness(); + state.send = async () => { throw Object.assign(new Error(), { code: 'DEPENDENCY_UNAVAILABLE', status: 503 }); }; + const firstStart = await queue.enqueueStart(context); + const repeatedStart = await queue.enqueueStart(context); + assert.equal(repeatedStart.payload.commandId, firstStart.payload.commandId); + const firstReport = await queue.enqueueDeliveryException(context, report); + const repeatedReport = await queue.enqueueDeliveryException(context, report); + assert.equal(repeatedReport.payload.commandId, firstReport.payload.commandId); + assert.equal(queue.listForAccount(accountId).length, 2); + assert.equal(firstStart.status, 'pending'); + assert.equal(firstReport.status, 'pending'); + }); + + it('does no work when the queue is empty or on initialization and list reads', async () => { + const { queue, state } = harness(); + await queue.initialize(); + queue.listForAccount(accountId); + await queue.retryPending(); + assert.equal(state.sends.length, 0); + }); + + for (const [field, replacement] of [ + ['assignmentEpoch', '4'], + ['assignmentGeneration', '6'], + ['expectedRouteVersionId', '77777777-7777-4777-8777-777777777777'], + ['routeVersion', 3], + ] as const) { + it(`blocks an old command when ${field} changes`, async () => { + const { queue, state } = harness(); + state.send = async () => { throw new TypeError('offline'); }; + await queue.enqueueStart(context); + state.contexts = [{ ...context, [field]: replacement }]; + await queue.retryPending(); + assert.equal(state.sends.length, 1); + assert.equal(queue.listForAccount(accountId)[0].status, 'blocked'); + assert.equal(queue.listForAccount(accountId)[0].lastError, 'FENCE_CHANGED'); + }); + } + + it('blocks a missing, completed, or cancelled execution without submitting a command', async () => { + for (const contexts of [[], [{ ...context, status: 'COMPLETED' }], [{ ...context, status: 'CANCELLED' }]]) { + const { queue, state } = harness(); + state.contexts = contexts; + const result = await queue.enqueueStart(context); + assert.equal(result.status, 'blocked'); + assert.equal(result.lastError, 'CONTEXT_NOT_AVAILABLE'); + assert.equal(state.sends.length, 0); + } + }); + + it('keeps commands from another account out of retry and UI data', async () => { + const { queue, state, makeQueue } = harness(); + state.send = async () => { throw new TypeError('offline'); }; + await queue.enqueueStart(context); + state.session = { accountId: otherAccountId, generation: 2 }; + const restarted = makeQueue(); + await restarted.retryPending(); + assert.equal(state.sends.length, 1); + assert.deepEqual(restarted.listForAccount(accountId), []); + assert.deepEqual(restarted.listForAccount(otherAccountId), []); + }); + + it('blocks an in-flight command on logout and ignores the late server response', async () => { + const { queue, state, store } = harness(); + const response = deferred(); + const sent = deferred(); + state.send = async (command) => { sent.resolve(command); return response.promise; }; + const pendingResult = queue.enqueueStart(context); + const command = await sent.promise; + state.session = null; + await queue.invalidateSession(); + const result = await pendingResult; + assert.equal(result.status, 'blocked'); + assert.equal(result.lastError, 'SESSION_CHANGED'); + response.resolve(acknowledge(command)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(store.saved[0].status, 'blocked'); + state.session = { accountId, generation: 3 }; + await queue.retryPending(); + assert.equal(state.sends.length, 1); + }); + + it('ignores a late response after the account generation changes', async () => { + const { queue, state } = harness(); + const response = deferred(); + const sent = deferred(); + state.send = async (command) => { sent.resolve(command); return response.promise; }; + const resultPromise = queue.enqueueStart(context); + const command = await sent.promise; + state.session = { accountId, generation: 2 }; + response.resolve(acknowledge(command)); + assert.equal((await resultPromise).status, 'blocked'); + }); + + it('retains definitive authority/conflict/terminal-target rejection without retrying it', async () => { + for (const code of ['UNAUTHORIZED', 'ASSIGNMENT_CHANGED', 'ROUTE_VERSION_CHANGED', 'TARGET_TERMINAL']) { + const { queue, state } = harness(); + state.send = async () => { throw Object.assign(new Error(), { code, status: code === 'UNAUTHORIZED' ? 403 : 409 }); }; + const result = await queue.enqueueDeliveryException(context, report); + assert.equal(result.status, 'blocked'); + assert.equal(result.lastError, code); + await queue.retryPending(); + assert.equal(state.sends.length, 1); + } + }); + + it('retains a 401 command for same-account authentication renewal and replays its original body', async () => { + const { queue, state, store } = harness(); + state.send = async () => { throw Object.assign(new Error(), { code: 'UNAUTHORIZED', status: 401 }); }; + const pending = await queue.enqueueStart(context); + assert.equal(pending.status, 'pending'); + assert.equal(store.saved[0].status, 'pending'); + assert.deepEqual(state.authenticationRequests, [{ accountId, generation: 1 }]); + // An access-token renewal leaves the account/login generation unchanged. + state.send = async (command) => acknowledge(command); + await queue.retryPending(); + assert.deepEqual(state.sends[0].payload, state.sends[1].payload); + assert.equal(queue.listForAccount(accountId)[0].status, 'confirmed'); + }); + + it('never replays another account\'s 401 command or requests recovery for a late old-session rejection', async () => { + const { queue, state } = harness(); + state.send = async () => { throw Object.assign(new Error(), { code: 'UNAUTHORIZED', status: 401 }); }; + const pending = await queue.enqueueDeliveryException(context, report); + assert.equal(pending.status, 'pending'); + state.session = { accountId: otherAccountId, generation: 2 }; + await queue.retryPending(); + assert.equal(state.sends.length, 1); + assert.deepEqual(queue.listForAccount(otherAccountId), []); + + const second = harness(); + const sent = deferred(); + const rejection = deferred(); + second.state.send = async () => { + sent.resolve(); + await rejection.promise; + throw Object.assign(new Error(), { code: 'UNAUTHORIZED', status: 401 }); + }; + const resultPromise = second.queue.enqueueStart(context); + await sent.promise; + second.state.session = { accountId: otherAccountId, generation: 2 }; + rejection.resolve(); + assert.equal((await resultPromise).status, 'blocked'); + assert.deepEqual(second.state.authenticationRequests, []); + await second.queue.retryPending(); + assert.equal(second.state.sends.length, 1); + }); + + it('records a report without creating start, failure, or completion commands', async () => { + const { queue, state } = harness(); + const result = await queue.enqueueDeliveryException(context, { ...report, explanation: '합성 검증' }); + assert.equal(result.status, 'confirmed'); + assert.deepEqual(state.sends.map((command) => command.type), ['REPORT_DELIVERY_EXCEPTION']); + assert.equal(state.sends[0].payload.occurredAt, '2026-10-07T00:00:00.000Z'); + }); + + it('deduplicates two concurrent report taps even when the first receives an immediate approval', async () => { + const { queue, state } = harness(); + const [first, second] = await Promise.all([ + queue.enqueueDeliveryException(context, report), + queue.enqueueDeliveryException(context, report), + ]); + assert.equal(first.status, 'confirmed'); + assert.equal(second.payload.commandId, first.payload.commandId); + assert.equal(state.sends.length, 1); + const later = await queue.enqueueDeliveryException(context, report); + assert.notEqual(later.payload.commandId, first.payload.commandId); + }); + + it('retries a mismatched acknowledgement with the original identity and body', async () => { + const { queue, state } = harness(); + state.send = async (command) => ({ ...acknowledge(command), routeVersion: 999 }); + const result = await queue.enqueueStart(context); + assert.equal(result.status, 'pending'); + assert.equal(result.lastError, 'INVALID_ACKNOWLEDGEMENT'); + state.send = async (command) => acknowledge(command); + await queue.retryPending(); + assert.deepEqual(state.sends[0].payload, state.sends[1].payload); + assert.equal(queue.listForAccount(accountId)[0].status, 'confirmed'); + }); + + it('retries invalid HTTP 200/201 response bodies after restart without changing the command', async () => { + for (const status of [200, 201]) { + const { queue, state, makeQueue } = harness(); + // DriverOperationalApiError preserves the successful HTTP status when body validation fails. + state.send = async () => { throw Object.assign(new Error(), { code: 'INVALID_RESPONSE', status }); }; + const result = await queue.enqueueDeliveryException(context, report); + assert.equal(result.status, 'pending'); + assert.equal(result.lastError, 'INVALID_RESPONSE'); + state.send = async (command) => acknowledge(command); + const restarted = makeQueue(); + await restarted.retryPending(); + assert.deepEqual(state.sends[0].payload, state.sends[1].payload); + assert.equal(restarted.listForAccount(accountId)[0].status, 'confirmed'); + } + }); + + it('never sends a command when durable persistence fails', async () => { + const { queue, state } = harness(); + state.failSave = true; + await assert.rejects(queue.enqueueStart(context), /storage failure/); + assert.equal(state.sends.length, 0); + }); + + it('does not confirm when logout occurs while the acknowledgement is being persisted', async () => { + const store = memoryStore(); + const state = { session: { accountId, generation: 1 } as DriverCommandSession | null }; + const write = deferred(); + const confirming = deferred(); + const queue = new DriverCommandQueue({ + createCommandId: () => '66666666-6666-4666-8666-000000000001', + getSession: () => state.session, + loadContexts: async () => [context], + now: () => '2026-10-07T00:00:00.000Z', + send: async (command) => acknowledge(command), + store: { + load: () => store.load(), + save: async (commands) => { + if (commands[0]?.status === 'confirmed') { + confirming.resolve(); + await write.promise; + } + await store.save(commands); + }, + }, + }); + const resultPromise = queue.enqueueStart(context); + await confirming.promise; + state.session = null; + const invalidation = queue.invalidateSession(); + write.resolve(); + const result = await resultPromise; + await invalidation; + assert.equal(result.status, 'blocked'); + assert.equal(store.saved[0].status, 'blocked'); + }); + + it('blocks restored pending commands on logout even before retry starts', async () => { + const { queue, makeQueue, state, store } = harness(); + state.send = async () => { throw new TypeError('offline'); }; + await queue.enqueueStart(context); + const restored = makeQueue(); + await restored.initialize(); + state.session = null; + await restored.invalidateSession(); + assert.equal(store.saved[0].status, 'blocked'); + }); + + it('permits a new explicit start after login without replaying a cancelled command', async () => { + const { queue, state } = harness(); + state.send = async () => { throw new TypeError('offline'); }; + const old = await queue.enqueueStart(context); + state.session = null; + await queue.invalidateSession(); + state.session = { accountId, generation: 2 }; + state.send = async (command) => acknowledge(command); + const fresh = await queue.enqueueStart(context); + assert.equal(fresh.status, 'confirmed'); + assert.notEqual(fresh.payload.commandId, old.payload.commandId); + assert.equal(queue.listForAccount(accountId)[0].status, 'blocked'); + assert.equal(state.sends.length, 2); + }); + + it('keeps a suspended in-flight command retryable after ordinary unmount/restart', async () => { + const { queue, state, store, makeQueue } = harness(); + const sent = deferred(); + const response = deferred(); + state.send = async (command) => { sent.resolve(command); return response.promise; }; + const attempt = queue.enqueueStart(context); + const oldCommand = await sent.promise; + state.session = null; + queue.suspendSession(); + assert.equal((await attempt).status, 'pending'); + assert.equal(store.saved[0].status, 'pending'); + response.resolve(acknowledge(oldCommand)); + state.session = { accountId, generation: 1 }; + state.send = async (command) => acknowledge(command); + const restored = makeQueue(); + await restored.retryPending(); + assert.equal(restored.listForAccount(accountId)[0].status, 'confirmed'); + assert.deepEqual(state.sends[0].payload, state.sends[1].payload); + }); + + it('gives explicit logout cancellation precedence over suspension', async () => { + const { queue, state, store } = harness(); + const sent = deferred(); + state.send = async (command) => { sent.resolve(command); return new Promise(() => undefined); }; + const attempt = queue.enqueueStart(context); + await sent.promise; + queue.suspendSession(); + state.session = null; + const logout = queue.invalidateSession(); + assert.equal((await attempt).status, 'blocked'); + await logout; + assert.equal(store.saved[0].status, 'blocked'); + }); + + it('bounds storage without discarding pending commands', async () => { + const { queue, state } = harness(); + state.send = async () => { throw new TypeError('offline'); }; + for (let index = 0; index < 40; index += 1) { + await queue.enqueueDeliveryException(context, { ...report, reasonCode: `SYNTHETIC_${index}` }); + } + await assert.rejects(queue.enqueueDeliveryException(context, { ...report, reasonCode: 'SYNTHETIC_OVERFLOW' }), + (error: unknown) => error instanceof DriverCommandQueueError && error.code === 'QUEUE_FULL'); + assert.equal(queue.listForAccount(accountId).length, 40); + }); + + it('rejects corrupted storage and excludes token/extra fields', async () => { + const { queue, store } = harness(); + await queue.enqueueStart(context); + assert.deepEqual(parseStoredDriverCommands(store.saved), store.saved); + assert.throws(() => parseStoredDriverCommands([{ ...store.saved[0], accessToken: 'never-store' }])); + assert.throws(() => parseStoredDriverCommands([{ ...store.saved[0], payload: { ...store.saved[0].payload, occurredAt: 'invalid' } }])); + }); +}); diff --git a/src/domain/delivery/driverCommandQueue.ts b/src/domain/delivery/driverCommandQueue.ts new file mode 100644 index 0000000..52c3120 --- /dev/null +++ b/src/domain/delivery/driverCommandQueue.ts @@ -0,0 +1,411 @@ +/** Small operational commands only. Tokens, photos, GPS, and local timers are excluded. */ +export type DriverCommandSession = { accountId: string; generation: number | string }; + +export type DriverCommandContext = { + assignmentEpoch: string; + assignmentGeneration: string | null; + executionContextId: string; + expectedRouteVersionId: string | null; + routeVersion: number; + startedAt: string | null; + status: string; +}; + +export type DriverCommandPayload = { + assignmentEpoch: string; + assignmentGeneration: string; + commandId: string; + expectedRouteVersionId: string; + occurredAt: string; + routeVersion: number; +}; + +export type DriverDeliveryExceptionDetails = { + explanation?: string | null; + reasonCode: string; + targetStopId: string; +}; + +type StoredCommandState = { + accountId: string; + attempts: number; + executionContextId: string; + lastError: string | null; + status: 'blocked' | 'confirmed' | 'pending'; +}; + +export type DriverQueuedCommand = StoredCommandState & ( + | { payload: DriverCommandPayload; type: 'START_EXECUTION' } + | { payload: DriverCommandPayload & DriverDeliveryExceptionDetails; type: 'REPORT_DELIVERY_EXCEPTION' } +); + +export type DriverCommandAcknowledgement = { + assignmentEpoch: string; + commandId: string; + executionContextId: string; + routeVersion: number; +}; + +export type DriverCommandStore = { + load(): Promise; + save(commands: readonly DriverQueuedCommand[]): Promise; +}; + +type DriverCommandQueueOptions = { + createCommandId(): string; + getSession(): DriverCommandSession | null; + loadContexts(session: DriverCommandSession): Promise; + now?: () => string; + onAuthenticationRequired?: (session: DriverCommandSession) => void | Promise; + onChange?: (commands: DriverQueuedCommand[]) => void; + send(command: DriverQueuedCommand, session: DriverCommandSession): Promise; + store: DriverCommandStore; +}; + +type SessionSnapshot = DriverCommandSession & { queueGeneration: number }; + +export class DriverCommandQueueError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'DriverCommandQueueError'; + } +} + +const MAX_COMMANDS = 40; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const POSITIVE_BIGINT = /^[1-9]\d{0,18}$/u; +const ERROR_CODE = /^[A-Z][A-Z0-9_]{0,79}$/u; +const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; + +/** Serialized writes prevent concurrent taps and lifecycle retries from duplicating commands. */ +export class DriverCommandQueue { + private commands: DriverQueuedCommand[] = []; + private initialized = false; + private work: Promise = Promise.resolve(); + private sessionGeneration = 0; + private lastAccountId: string | null = null; + private readonly interruptions = new Set<() => void>(); + private readonly suspendedGenerations = new Set(); + private readonly enqueues = new Map>(); + + constructor(private readonly options: DriverCommandQueueOptions) {} + + initialize(): Promise { + return this.serialize(() => this.load()); + } + + listForAccount(accountId: string): DriverQueuedCommand[] { + if (this.options.getSession()?.accountId !== accountId) return []; + return this.commands.filter((command) => command.accountId === accountId).map(cloneCommand); + } + + async enqueueStart(context: DriverCommandContext): Promise { + return this.enqueue({ ...context }, 'START_EXECUTION'); + } + + async enqueueDeliveryException( + context: DriverCommandContext, + details: DriverDeliveryExceptionDetails, + ): Promise { + return this.enqueue({ ...context }, 'REPORT_DELIVERY_EXCEPTION', { ...details }); + } + + async retryPending(): Promise { + const session = this.captureSession(); + await this.serialize(async () => { + await this.load(); + this.assertSession(session); + const pendingIds = this.commands + .filter((command) => command.accountId === session.accountId && command.status === 'pending') + .map((command) => command.payload.commandId); + for (const commandId of pendingIds) { + if (!this.isCurrentSession(session)) break; + const command = this.commands.find((item) => item.payload.commandId === commandId); + if (command?.status === 'pending') await this.attempt(command, session); + } + }); + } + + /** Call immediately on logout/account switch. It releases hung requests and durably blocks old work. */ + invalidateSession(): Promise { + const accountId = this.lastAccountId ?? this.options.getSession()?.accountId ?? null; + this.suspendedGenerations.clear(); + this.sessionGeneration += 1; + for (const interrupt of [...this.interruptions]) interrupt(); + this.publish(); + return this.serialize(async () => { + await this.load(); + if (accountId === null) return; + const next = this.commands.map((command): DriverQueuedCommand => + command.accountId === accountId && command.status === 'pending' + ? { ...command, lastError: 'SESSION_CHANGED', status: 'blocked' } + : command); + await this.save(next); + }); + } + + /** Detach/expired authentication stops local work, while a later same-account login can replay it. */ + suspendSession(): void { + this.suspendedGenerations.add(this.sessionGeneration); + this.sessionGeneration += 1; + for (const interrupt of [...this.interruptions]) interrupt(); + } + + private async enqueue( + context: DriverCommandContext, + type: DriverQueuedCommand['type'], + details?: DriverDeliveryExceptionDetails, + ): Promise { + const session = this.captureSession(); + const inputKey = JSON.stringify([ + session.accountId, session.generation, session.queueGeneration, + context.executionContextId, context.routeVersion, context.assignmentEpoch, + context.assignmentGeneration, context.expectedRouteVersionId, type, + details?.targetStopId ?? null, details?.reasonCode ?? null, details?.explanation ?? null, + ]); + const concurrent = this.enqueues.get(inputKey); + if (concurrent !== undefined) return concurrent; + const result = this.serialize(async () => { + await this.load(); + this.assertSession(session); + if (context.assignmentGeneration === null || context.expectedRouteVersionId === null) { + throw new DriverCommandQueueError('LEGACY_FENCE_REQUIRED'); + } + const existing = this.commands.find((command) => + command.accountId === session.accountId && command.type === type + && command.status !== 'blocked' + && sameFences(command, context) + && (type === 'START_EXECUTION' || command.status !== 'confirmed') + && (type === 'START_EXECUTION' || (command.type === 'REPORT_DELIVERY_EXCEPTION' + && command.payload.targetStopId === details?.targetStopId + && command.payload.reasonCode === details?.reasonCode + && (command.payload.explanation ?? null) === (details?.explanation ?? null)))); + if (existing !== undefined) { + return existing.status === 'pending' ? this.attempt(existing, session) : cloneCommand(existing); + } + const payload: DriverCommandPayload = { + assignmentEpoch: context.assignmentEpoch, + assignmentGeneration: context.assignmentGeneration, + commandId: this.options.createCommandId(), + expectedRouteVersionId: context.expectedRouteVersionId, + occurredAt: (this.options.now ?? (() => new Date().toISOString()))(), + routeVersion: context.routeVersion, + }; + const state: StoredCommandState = { + accountId: session.accountId, attempts: 0, + executionContextId: context.executionContextId, + lastError: null, status: 'pending', + }; + const command: DriverQueuedCommand = type === 'START_EXECUTION' + ? { ...state, payload, type } + : { ...state, payload: { ...payload, ...details! }, type }; + // Validate data even when an injected ID/reason source or persisted storage is malformed. + parseStoredDriverCommands([command]); + const retained = [...this.commands]; + if (retained.length >= MAX_COMMANDS) { + const confirmedIndex = retained.findIndex((item) => item.status === 'confirmed'); + if (confirmedIndex < 0) throw new DriverCommandQueueError('QUEUE_FULL'); + retained.splice(confirmedIndex, 1); + } + await this.save([...retained, command]); + return this.attempt(command, session); + }); + this.enqueues.set(inputKey, result); + try { return await result; } finally { this.enqueues.delete(inputKey); } + } + + private async attempt(command: DriverQueuedCommand, session: SessionSnapshot): Promise { + try { + this.assertSession(session); + const contexts = await this.withSession(() => this.options.loadContexts(session), session); + const current = contexts.find((item) => item.executionContextId === command.executionContextId); + if (current === undefined || current.status !== 'ACTIVE') { + throw new DriverCommandQueueError('CONTEXT_NOT_AVAILABLE'); + } + if (!sameFences(command, current)) throw new DriverCommandQueueError('FENCE_CHANGED'); + // startedAt is deliberately not treated as this command's receipt: replay requires a server ACK. + command = { ...command, attempts: command.attempts + 1, lastError: null }; + await this.replace(command); + this.assertSession(session); + const result = await this.withSession(() => this.options.send(cloneCommand(command), session), session); + if (result.commandId !== command.payload.commandId + || result.executionContextId !== command.executionContextId + || result.assignmentEpoch !== command.payload.assignmentEpoch + || result.routeVersion !== command.payload.routeVersion) { + throw new DriverCommandQueueError('INVALID_ACKNOWLEDGEMENT'); + } + this.assertSession(session); + const confirmed: DriverQueuedCommand = { ...command, lastError: null, status: 'confirmed' }; + await this.replace(confirmed); + this.assertSession(session); + return cloneCommand(confirmed); + } catch (error) { + if (this.suspendedGenerations.has(session.queueGeneration)) { + // The pending body is already durable. A detached queue must not overwrite a new queue's writes. + return cloneCommand({ ...command, lastError: 'SESSION_SUSPENDED', status: 'pending' }); + } + const failure = this.isCurrentSession(session) + ? classifyFailure(error) + : { lastError: 'SESSION_CHANGED', status: 'blocked' as const }; + const failed: DriverQueuedCommand = { ...command, ...failure }; + await this.replace(failed); + if (this.isCurrentSession(session) && error !== null && typeof error === 'object' + && 'status' in error && error.status === 401) { + this.requestAuthentication(session); + } + return cloneCommand(failed); + } finally { + this.suspendedGenerations.delete(session.queueGeneration); + } + } + + private async withSession(operation: () => Promise, session: SessionSnapshot): Promise { + this.assertSession(session); + let interrupt!: () => void; + const interrupted = new Promise((_resolve, reject) => { + interrupt = () => reject(new DriverCommandQueueError('SESSION_CHANGED')); + }); + this.interruptions.add(interrupt); + try { + const result = await Promise.race([operation(), interrupted]); + this.assertSession(session); + return result; + } finally { + this.interruptions.delete(interrupt); + } + } + + private captureSession(): SessionSnapshot { + const session = this.options.getSession(); + if (session === null) throw new DriverCommandQueueError('AUTHENTICATION_REQUIRED'); + this.lastAccountId = session.accountId; + return { ...session, queueGeneration: this.sessionGeneration }; + } + + private requestAuthentication(session: DriverCommandSession): void { + const recover = this.options.onAuthenticationRequired; + if (recover === undefined) return; + try { + // Recovery can schedule a retry; awaiting it here would deadlock the serialized queue. + void Promise.resolve(recover({ accountId: session.accountId, generation: session.generation })) + .catch(() => undefined); + } catch { /* A failed authentication recovery leaves the original command pending. */ } + } + + private isCurrentSession(session: SessionSnapshot): boolean { + const current = this.options.getSession(); + return current?.accountId === session.accountId && current.generation === session.generation + && session.queueGeneration === this.sessionGeneration; + } + + private assertSession(session: SessionSnapshot): void { + if (!this.isCurrentSession(session)) throw new DriverCommandQueueError('SESSION_CHANGED'); + } + + private serialize(operation: () => Promise): Promise { + const result = this.work.then(operation); + this.work = result.then(() => undefined, () => undefined); + return result; + } + + private async load(): Promise { + if (this.initialized) return; + this.commands = parseStoredDriverCommands(await this.options.store.load()); + this.initialized = true; + this.lastAccountId = this.options.getSession()?.accountId ?? this.lastAccountId; + this.publish(); + } + + private async replace(command: DriverQueuedCommand): Promise { + await this.save(this.commands.map((item) => item.payload.commandId === command.payload.commandId ? command : item)); + } + + private async save(commands: DriverQueuedCommand[]): Promise { + const validated = parseStoredDriverCommands(commands); + await this.options.store.save(validated); + this.commands = validated.map(cloneCommand); + this.publish(); + } + + private publish(): void { + const accountId = this.options.getSession()?.accountId; + this.options.onChange?.(accountId === undefined ? [] : this.listForAccount(accountId)); + } +} + +function sameFences(command: DriverQueuedCommand, context: DriverCommandContext): boolean { + return command.executionContextId === context.executionContextId + && command.payload.routeVersion === context.routeVersion + && command.payload.assignmentEpoch === context.assignmentEpoch + && command.payload.assignmentGeneration === context.assignmentGeneration + && command.payload.expectedRouteVersionId === context.expectedRouteVersionId; +} + +function cloneCommand(command: DriverQueuedCommand): DriverQueuedCommand { + return { ...command, payload: { ...command.payload } } as DriverQueuedCommand; +} + +function classifyFailure(error: unknown): { lastError: string; status: 'blocked' | 'pending' } { + if (error instanceof DriverCommandQueueError) { + return { lastError: error.code, status: error.code === 'INVALID_ACKNOWLEDGEMENT' ? 'pending' : 'blocked' }; + } + const value = error !== null && typeof error === 'object' ? error as { status?: unknown; code?: unknown } : {}; + const status = typeof value.status === 'number' ? value.status : 0; + const lastError = typeof value.code === 'string' && ERROR_CODE.test(value.code) ? value.code : 'NETWORK_RETRY'; + // A successful HTTP status without a validated receipt leaves the server outcome unknown. + const retryable = status === 0 || status === 401 || (status >= 200 && status < 300) + || status >= 500 || status === 408 || status === 429; + return { lastError, status: retryable ? 'pending' : 'blocked' }; +} + +/** Fail closed on corrupt/unexpected storage. Access tokens are never valid stored command fields. */ +export function parseStoredDriverCommands(value: unknown): DriverQueuedCommand[] { + if (!Array.isArray(value) || value.length > MAX_COMMANDS) throw new DriverCommandQueueError('STORAGE_INVALID'); + const ids = new Set(); + const commands: DriverQueuedCommand[] = []; + for (const item of value) { + if (!isRecord(item) || !exactKeys(item, ['accountId', 'attempts', 'executionContextId', 'lastError', 'payload', 'status', 'type']) + || !isUuid(item.accountId) || !isUuid(item.executionContextId) + || !Number.isSafeInteger(item.attempts) || Number(item.attempts) < 0 + || !['pending', 'blocked', 'confirmed'].includes(String(item.status)) + || (item.lastError !== null && (typeof item.lastError !== 'string' || !ERROR_CODE.test(item.lastError))) + || !['START_EXECUTION', 'REPORT_DELIVERY_EXCEPTION'].includes(String(item.type)) + || !isRecord(item.payload)) throw new DriverCommandQueueError('STORAGE_INVALID'); + const payload = item.payload; + const keys = ['assignmentEpoch', 'assignmentGeneration', 'commandId', 'expectedRouteVersionId', 'occurredAt', 'routeVersion']; + if (item.type === 'REPORT_DELIVERY_EXCEPTION') keys.push('targetStopId', 'reasonCode', 'explanation'); + if (!exactKeys(payload, keys) || !isUuid(payload.commandId) || ids.has(payload.commandId) + || !isUuid(payload.expectedRouteVersionId) || !isPositiveBigint(payload.assignmentEpoch) + || !isPositiveBigint(payload.assignmentGeneration) || !Number.isSafeInteger(payload.routeVersion) + || Number(payload.routeVersion) < 1 || typeof payload.occurredAt !== 'string' + || !ISO_INSTANT.test(payload.occurredAt) || !Number.isFinite(Date.parse(payload.occurredAt))) { + throw new DriverCommandQueueError('STORAGE_INVALID'); + } + if (item.type === 'REPORT_DELIVERY_EXCEPTION' && (!isUuid(payload.targetStopId) + || typeof payload.reasonCode !== 'string' || payload.reasonCode.trim() !== payload.reasonCode + || payload.reasonCode.length < 1 || payload.reasonCode.length > 80 + || (payload.explanation !== undefined && payload.explanation !== null + && (typeof payload.explanation !== 'string' || payload.explanation.length > 1_000)))) { + throw new DriverCommandQueueError('STORAGE_INVALID'); + } + ids.add(payload.commandId); + commands.push(cloneCommand(item as DriverQueuedCommand)); + } + return commands; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys(value: Record, allowed: string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function isUuid(value: unknown): value is string { + return typeof value === 'string' && UUID.test(value); +} + +function isPositiveBigint(value: unknown): value is string { + return typeof value === 'string' && POSITIVE_BIGINT.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n; +} diff --git a/src/domain/notifications/driverNotificationRecovery.test.ts b/src/domain/notifications/driverNotificationRecovery.test.ts new file mode 100644 index 0000000..0231dbe --- /dev/null +++ b/src/domain/notifications/driverNotificationRecovery.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createDriverNotificationRecovery } from './driverNotificationRecovery'; +import type { DriverNotificationClick } from './driverPushNotification'; + +const notification: DriverNotificationClick = { + kind: 'N05', notificationId: '31200000-0000-4000-8000-000000000001', + expiresAt: '2026-10-07T01:00:00.000Z', schemaVersion: '1', status: 'current', +}; +function memoryStorage() { + let value: string | null = null; + return { getItem: async (_key: string) => value, + setItem: async (_key: string, next: string) => { value = next; }, + removeItem: async (_key: string) => { value = null; }, + read: () => value }; +} +function recovery(storage = memoryStorage()) { + return createDriverNotificationRecovery(storage, { now: () => Date.parse('2026-10-07T00:00:00.000Z') }); +} + +describe('Persistent notification click recovery', () => { + it('retains a pre-login click through auth recovery and process restart', async () => { + const storage = memoryStorage(); + const first = recovery(storage); + await first.restore(); + await first.receiveClick(notification); + assert.equal(first.acquirePending(), null); + const restarted = recovery(storage); + await restarted.restore(); + await restarted.setAccount('account-A'); + const lease = restarted.acquirePending(); + assert.equal(lease?.notification.notificationId, notification.notificationId); + restarted.release(lease!); + await restarted.setAccount(null); + assert.equal(restarted.acquirePending(), null); + await restarted.setAccount('account-A'); + assert.equal(restarted.acquirePending()?.notification.notificationId, notification.notificationId); + assert.doesNotMatch(storage.read()!, /accessToken|password|routePlanId|targetStopId/); + }); + + it('deduplicates only accepted destinations and retries unresolved clicks', async () => { + const state = recovery(); + await state.restore(); + await state.setAccount('account-A'); + await state.receiveClick(notification); + const unresolved = state.acquirePending()!; + assert.equal(state.acquirePending(), null); + assert.equal(await state.receiveClick(notification), false); + state.release(unresolved); + const accepted = state.acquirePending()!; + assert.equal(await state.accept(accepted), true); + assert.equal(await state.receiveClick(notification), false); + assert.equal(state.acquirePending(), null); + }); + + it('invalidates late resolution on logout and does not mix account clicks', async () => { + const storage = memoryStorage(); + const state = recovery(storage); + await state.restore(); + await state.setAccount('account-A'); + await state.receiveClick(notification); + const lease = state.acquirePending()!; + await state.clearForLogout(); + assert.equal(state.isCurrent(lease), false); + assert.equal(await state.accept(lease), false); + await state.setAccount('account-B'); + assert.equal(state.acquirePending(), null); + await state.receiveClick(notification); + const second = state.acquirePending()!; + await state.setAccount('account-C'); + assert.equal(state.isCurrent(second), false); + assert.equal(state.acquirePending(), null); + assert.equal(await state.accept(second), false); + }); + + it('clears a restored account-bound click when a different account signs in', async () => { + const storage = memoryStorage(); + const first = recovery(storage); + await first.restore(); + await first.setAccount('account-A'); + await first.receiveClick(notification); + const second = recovery(storage); + await second.restore(); + await second.setAccount('account-B'); + assert.equal(second.acquirePending(), null); + }); + + it('rechecks expiry after restart and safely discards malformed storage', async () => { + const storage = memoryStorage(); + const first = recovery(storage); + await first.restore(); + await first.receiveClick(notification); + const second = createDriverNotificationRecovery(storage, { now: () => Date.parse('2026-10-08T00:00:00.000Z') }); + await second.restore(); + await second.setAccount('account-A'); + const pending = second.acquirePending()?.notification; + assert.equal(pending?.kind === 'N05' ? pending.status : null, 'expired'); + await storage.setItem('key', '{invalid'); + const corrupt = recovery(storage); + await corrupt.restore(); + await corrupt.setAccount('account-A'); + assert.equal(corrupt.acquirePending(), null); + }); +}); + + +it('retains successful push deduplication across restart but permits explicit inbox reopening', async () => { + const storage = memoryStorage(); + const first = recovery(storage); + await first.restore(); + await first.setAccount('account-A'); + await first.receiveClick(notification); + await first.accept(first.acquirePending()!); + const second = recovery(storage); + await second.restore(); + await second.setAccount('account-A'); + assert.equal(await second.receiveClick(notification), false); + assert.equal(await second.receiveClick(notification, { reopen: true }), true); + assert.equal(second.acquirePending()?.notification.notificationId, notification.notificationId); +}); + +it('invalidates a lease immediately when account suspension begins', async () => { + const state = recovery(); + await state.restore(); + await state.setAccount('account-A'); + await state.receiveClick(notification); + const lease = state.acquirePending()!; + const suspension = state.setAccount(null); + assert.equal(state.isCurrent(lease), false); + await suspension; + assert.equal(await state.accept(lease), false); +}); + +it('retries receiveClick after storage failure without deduplicating an unsaved click', async () => { + const base = memoryStorage(); + let fail = false; + const storage = { ...base, setItem: async (key: string, value: string) => { + if (fail) throw new Error('storage unavailable'); + await base.setItem(key, value); + } }; + const state = recovery(storage); + await state.restore(); await state.setAccount('account-A'); + fail = true; + await assert.rejects(state.receiveClick(notification), /storage unavailable/u); + assert.equal(state.acquirePending(), null); + fail = false; + assert.equal(await state.receiveClick(notification), true); + assert.equal(state.acquirePending()?.notification.notificationId, notification.notificationId); +}); + +it('retains the active lease and pending click when accepted-identity storage fails', async () => { + const base = memoryStorage(); + let fail = false; + const storage = { ...base, setItem: async (key: string, value: string) => { + if (fail) throw new Error('storage unavailable'); + await base.setItem(key, value); + } }; + const state = recovery(storage); + await state.restore(); await state.setAccount('account-A'); await state.receiveClick(notification); + const lease = state.acquirePending()!; + fail = true; + await assert.rejects(state.accept(lease), /storage unavailable/u); + assert.equal(state.isCurrent(lease), true); + assert.match(base.read()!, /pending.*N05/u); + fail = false; + assert.equal(await state.accept(lease), true); + assert.equal(await state.receiveClick(notification), false); +}); + +it('keeps logout authoritative when an accepted-identity write finishes late', async () => { + const base = memoryStorage(); + let finishWrite!: () => void; + const write = new Promise((resolve) => { finishWrite = resolve; }); + const storage = { ...base, setItem: async (key: string, value: string) => { + if ((JSON.parse(value) as { accepted: string[] }).accepted.length > 0) await write; + await base.setItem(key, value); + } }; + const state = recovery(storage); + await state.restore(); await state.setAccount('account-A'); await state.receiveClick(notification); + const lease = state.acquirePending()!; + const accepted = state.accept(lease); + await new Promise((resolve) => setImmediate(resolve)); + const logout = state.clearForLogout(); + assert.equal(state.isCurrent(lease), false); + finishWrite(); + assert.equal(await accepted, false); + await logout; await state.setAccount('account-B'); + assert.equal(state.acquirePending(), null); + assert.doesNotMatch(base.read()!, /N05/u); +}); + +it('keeps a click durable if authentication is suspended during its storage write', async () => { + const base = memoryStorage(); + let finishWrite!: () => void; + const write = new Promise((resolve) => { finishWrite = resolve; }); + const storage = { ...base, setItem: async (key: string, value: string) => { + if ((JSON.parse(value) as { pending: unknown[] }).pending.length > 0) await write; + await base.setItem(key, value); + } }; + const state = recovery(storage); + await state.restore(); await state.setAccount('account-A'); + const received = state.receiveClick(notification); + await new Promise((resolve) => setImmediate(resolve)); + const suspension = state.setAccount(null); + finishWrite(); + assert.equal(await received, true); + await suspension; await state.setAccount('account-A'); + assert.equal(state.acquirePending()?.notification.notificationId, notification.notificationId); +}); diff --git a/src/domain/notifications/driverNotificationRecovery.ts b/src/domain/notifications/driverNotificationRecovery.ts new file mode 100644 index 0000000..fd9e4e7 --- /dev/null +++ b/src/domain/notifications/driverNotificationRecovery.ts @@ -0,0 +1,166 @@ +import { + isDriverOperationalPushNotification, + parseDriverPushNotification, + type DriverNotificationClick, +} from './driverPushNotification'; + +export type DriverNotificationClickStorage = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +}; +export type DriverNotificationClickLease = { + accountId: string; + attemptId: number; + generation: number; + notification: DriverNotificationClick; +}; +const STORAGE_KEY = 'driver.push.pending-clicks.v1'; +const MAX_IDENTITIES = 32; +type StoredClicks = { accountId: string | null; accepted: string[]; pending: DriverNotificationClick[] }; + +/** Stores notification identities only. Destination details must come from the authenticated resolver. */ +export function createDriverNotificationRecovery( + storage: DriverNotificationClickStorage, + options: { now?: () => number } = {}, +) { + const now = options.now ?? Date.now; + let stored: StoredClicks = { accountId: null, accepted: [], pending: [] }; + let currentAccountId: string | null = null; + let generation = 0; + let identityRevision = 0; + let resetIdentities = false; + let attemptId = 0; + let activeLease: DriverNotificationClickLease | null = null; + let tail: Promise = Promise.resolve(); + const serialize = (operation: () => Promise): Promise => { + const next = tail.then(operation); + tail = next.catch(() => undefined); + return next; + }; + const empty = (): StoredClicks => ({ accountId: null, accepted: [], pending: [] }); + const snapshot = () => resetIdentities ? empty() : stored; + const persist = (next: StoredClicks) => storage.setItem(STORAGE_KEY, JSON.stringify(next)); + const isCurrent = (lease: DriverNotificationClickLease) => + currentAccountId === lease.accountId && generation === lease.generation + && activeLease?.attemptId === lease.attemptId; + + return { + restore: () => serialize(async () => { + const value = await storage.getItem(STORAGE_KEY); + if (value === null) return; + try { + const parsed = JSON.parse(value) as unknown; + if (!isRecord(parsed) + || !(parsed.accountId === null || typeof parsed.accountId === 'string') + || !Array.isArray(parsed.accepted) || parsed.accepted.length > MAX_IDENTITIES + || !parsed.accepted.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 340) + || !Array.isArray(parsed.pending) || parsed.pending.length > MAX_IDENTITIES) throw new Error('INVALID_CLICK_STORAGE'); + const pending = parsed.pending.map((identity) => parseStoredIdentity(identity, now())); + if (pending.some((identity) => identity === null)) throw new Error('INVALID_CLICK_STORAGE'); + stored = { accountId: parsed.accountId, accepted: parsed.accepted, pending: pending as DriverNotificationClick[] }; + } catch { + stored = { accountId: null, accepted: [], pending: [] }; + await storage.removeItem(STORAGE_KEY); + } + }), + setAccount: (accountId: string | null) => { + if (accountId !== null && ((currentAccountId !== null && currentAccountId !== accountId) + || (!resetIdentities && stored.accountId !== null && stored.accountId !== accountId))) { + identityRevision += 1; + } + if (currentAccountId !== accountId) { + generation += 1; + activeLease = null; + } + currentAccountId = accountId; + const revision = identityRevision; + return serialize(async () => { + if (accountId === null || revision !== identityRevision) return; + const previous = snapshot(); + const next: StoredClicks = previous.accountId !== null && previous.accountId !== accountId + ? { accountId, accepted: [], pending: [] } + : { ...previous, accountId }; + await persist(next); + if (revision === identityRevision) { stored = next; resetIdentities = false; } + }); + }, + receiveClick: (notification: DriverNotificationClick, options: { reopen?: boolean } = {}) => { + const revision = identityRevision; + return serialize(async () => { + if (revision !== identityRevision) return false; + const identity = parseStoredIdentity(notification, now()); + if (identity === null) throw new Error('INVALID_NOTIFICATION_IDENTITY'); + const key = identityKey(identity); + const previous = snapshot(); + if (previous.pending.some((entry) => identityKey(entry) === key)) return false; + if (previous.accepted.includes(key) && !options.reopen) return false; + if (previous.pending.length >= MAX_IDENTITIES) throw new Error('NOTIFICATION_CLICK_QUEUE_FULL'); + const next: StoredClicks = { ...previous, + accepted: previous.accepted.filter((entry) => entry !== key), pending: [...previous.pending, identity] }; + await persist(next); + if (revision !== identityRevision) return false; + stored = next; + resetIdentities = false; + return true; + }); + }, + acquirePending: (): DriverNotificationClickLease | null => { + if (resetIdentities || currentAccountId === null || stored.accountId !== currentAccountId + || activeLease !== null || stored.pending.length === 0) return null; + const notification = parseStoredIdentity(stored.pending[0], now())!; + activeLease = { accountId: currentAccountId, attemptId: ++attemptId, generation, notification }; + return activeLease; + }, + isCurrent, + accept: (lease: DriverNotificationClickLease) => serialize(async () => { + if (!isCurrent(lease)) return false; + const key = identityKey(lease.notification); + const next: StoredClicks = { ...stored, + pending: stored.pending.filter((entry) => identityKey(entry) !== key), + accepted: [...stored.accepted.filter((entry) => entry !== key), key].slice(-MAX_IDENTITIES) }; + await persist(next); + if (!isCurrent(lease)) return false; + stored = next; + activeLease = null; + return true; + }), + release: (lease: DriverNotificationClickLease) => { + if (isCurrent(lease)) activeLease = null; + }, + clearForLogout: () => { + // Invalidate async resolver results before waiting for storage operations. + generation += 1; + identityRevision += 1; + resetIdentities = true; + const revision = identityRevision; + currentAccountId = null; + activeLease = null; + return serialize(async () => { + await storage.removeItem(STORAGE_KEY); + if (revision === identityRevision) { stored = empty(); resetIdentities = false; } + }); + }, + }; +} + +function identityKey(notification: DriverNotificationClick): string { + return `${notification.kind}:${notification.notificationId}`; +} +function parseStoredIdentity(value: unknown, now: number): DriverNotificationClick | null { + if (!isRecord(value) || typeof value.notificationId !== 'string' || value.notificationId.length === 0 || value.notificationId.length > 300) return null; + if (value.kind === 'route_changed') return { kind: 'route_changed', notificationId: value.notificationId }; + if (value.kind === 'bundle_handoff') { + return parseDriverPushNotification(value.notificationId, { + type: 'driver_bundle_handoff', handoffRequestId: value.handoffRequestId, handoffEvent: value.event, + }, now); + } + if (value.kind === 'unsupported') return { kind: 'unsupported', notificationId: value.notificationId, reason: 'UNSUPPORTED_PAYLOAD' }; + const parsed = parseDriverPushNotification(value.notificationId, { + kind: value.kind, notificationId: value.notificationId, expiresAt: value.expiresAt, schemaVersion: value.schemaVersion, + }, now); + return parsed !== null && isDriverOperationalPushNotification(parsed) ? parsed : null; +} +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/domain/notifications/driverPushNotification.test.ts b/src/domain/notifications/driverPushNotification.test.ts index d450fba..37f8494 100644 --- a/src/domain/notifications/driverPushNotification.test.ts +++ b/src/domain/notifications/driverPushNotification.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { parseDriverPushNotification } from './driverPushNotification'; +import { classifyDriverNotificationClick, isDriverOperationalPushNotification, parseDriverPushNotification } from './driverPushNotification'; describe('Driver push notification payload', () => { it('accepts any route change with the server route identifier', () => { @@ -43,3 +43,44 @@ describe('Driver push notification payload', () => { }), null); }); }); + +const OPERATIONAL_ID = '31200000-0000-4000-8000-000000000001'; +const NOW = Date.parse('2026-10-07T00:00:00.000Z'); + +describe('Operational push schema v1', () => { + it('parses N01 through N06 using the server notification identity', () => { + for (const kind of ['N01', 'N02', 'N03', 'N04', 'N05', 'N06']) { + assert.deepEqual(parseDriverPushNotification('provider-id', { + expiresAt: '2026-10-07T01:00:00.000Z', kind, + notificationId: OPERATIONAL_ID, schemaVersion: '1', + }, NOW), { + expiresAt: '2026-10-07T01:00:00.000Z', kind, + notificationId: OPERATIONAL_ID, schemaVersion: '1', status: 'current', + }); + } + }); + + it('retains an expired identity for a safe visible click result', () => { + const parsed = parseDriverPushNotification('provider-id', { + expiresAt: '2026-10-06T00:00:00.000Z', kind: 'N05', + notificationId: OPERATIONAL_ID, schemaVersion: '1', + }, NOW); + assert.equal(parsed !== null && isDriverOperationalPushNotification(parsed) ? parsed.status : null, 'expired'); + }); + + it('rejects invalid IDs, versions, instants, kinds, and payload destinations', () => { + const valid = { expiresAt: '2026-10-07T01:00:00.000Z', kind: 'N06', notificationId: OPERATIONAL_ID, schemaVersion: '1' }; + for (const patch of [ + { schemaVersion: 1 }, { schemaVersion: '2' }, { notificationId: 'symbolic-id' }, + { expiresAt: '2026-10-07T01:00:00+00:00' }, { expiresAt: '2026-02-30T01:00:00.000Z' }, + { kind: 'N07' }, { routePlanId: OPERATIONAL_ID }, { targetStopId: OPERATIONAL_ID }, + ]) assert.equal(parseDriverPushNotification('provider-id', { ...valid, ...patch }, NOW), null); + }); +}); + + +it('provides a visible safe click for unsupported payload without forwarding details', () => { + assert.deepEqual(classifyDriverNotificationClick('provider-unsupported', { + kind: 'N07', schemaVersion: '2', routePlanId: 'unsafe-route', targetStopId: 'unsafe-stop', customer: 'private', + }), { kind: 'unsupported', notificationId: 'provider-unsupported', reason: 'UNSUPPORTED_PAYLOAD' }); +}); diff --git a/src/domain/notifications/driverPushNotification.ts b/src/domain/notifications/driverPushNotification.ts index d58db16..a965b05 100644 --- a/src/domain/notifications/driverPushNotification.ts +++ b/src/domain/notifications/driverPushNotification.ts @@ -1,43 +1,66 @@ +export const DRIVER_OPERATIONAL_NOTIFICATION_KINDS = ['N01', 'N02', 'N03', 'N04', 'N05', 'N06'] as const; +export type DriverOperationalPushKind = typeof DRIVER_OPERATIONAL_NOTIFICATION_KINDS[number]; +export type DriverOperationalPushNotification = { + expiresAt: string; + kind: DriverOperationalPushKind; + notificationId: string; + schemaVersion: '1'; + status: 'current' | 'expired'; +}; export type DriverPushNotification = - | { - kind: 'route_changed'; - notificationId: string; - } + | DriverOperationalPushNotification + | { kind: 'route_changed'; notificationId: string } | { event?: 'applied' | 'cancelled' | 'invalidated' | 'proposed' | 'rejected'; handoffRequestId: string; kind: 'bundle_handoff'; notificationId: string; }; +export type DriverNotificationClick = DriverPushNotification | { + kind: 'unsupported'; + notificationId: string; + reason: 'UNSUPPORTED_PAYLOAD'; +}; -type DriverBundleHandoffEvent = NonNullable< - Extract['event'] ->; +type DriverBundleHandoffEvent = NonNullable['event']>; +const HANDOFF_EVENTS = new Set(['applied', 'cancelled', 'invalidated', 'proposed', 'rejected']); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; +const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; -const HANDOFF_EVENTS = new Set([ - 'applied', - 'cancelled', - 'invalidated', - 'proposed', - 'rejected', -]); +export function isDriverOperationalPushNotification( + notification: DriverNotificationClick, +): notification is DriverOperationalPushNotification { + return (DRIVER_OPERATIONAL_NOTIFICATION_KINDS as readonly string[]).includes(notification.kind); +} export function parseDriverPushNotification( notificationId: string, data: Record, + now = Date.now(), ): DriverPushNotification | null { - if (data.type === 'driver_route_changed' && isNonEmptyString(data.routePlanId)) { + if ('schemaVersion' in data || 'kind' in data) { + if (data.schemaVersion !== '1' + || typeof data.kind !== 'string' + || !(DRIVER_OPERATIONAL_NOTIFICATION_KINDS as readonly string[]).includes(data.kind) + || typeof data.notificationId !== 'string' || !UUID.test(data.notificationId) + || !isUtcInstant(data.expiresAt) + || Object.keys(data).some((key) => !['schemaVersion', 'kind', 'notificationId', 'expiresAt'].includes(key))) { + return null; + } return { - kind: 'route_changed', - notificationId, + expiresAt: data.expiresAt, + kind: data.kind as DriverOperationalPushKind, + notificationId: data.notificationId, + schemaVersion: '1', + status: Date.parse(data.expiresAt) <= now ? 'expired' : 'current', }; } - if (data.type !== 'driver_bundle_handoff' || !isNonEmptyString(data.handoffRequestId)) { - return null; + if (data.type === 'driver_route_changed' && isNonEmptyString(data.routePlanId)) { + return { kind: 'route_changed', notificationId }; } + if (data.type !== 'driver_bundle_handoff' || !isNonEmptyString(data.handoffRequestId)) return null; const event = isNonEmptyString(data.handoffEvent) && HANDOFF_EVENTS.has(data.handoffEvent) - ? data.handoffEvent as DriverBundleHandoffEvent - : undefined; + ? data.handoffEvent as DriverBundleHandoffEvent : undefined; return { ...(event === undefined ? {} : { event }), handoffRequestId: data.handoffRequestId, @@ -46,6 +69,21 @@ export function parseDriverPushNotification( }; } +export function classifyDriverNotificationClick( + notificationId: string, + data: Record, + now = Date.now(), +): DriverNotificationClick { + return parseDriverPushNotification(notificationId, data, now) + ?? { kind: 'unsupported', notificationId, reason: 'UNSUPPORTED_PAYLOAD' }; +} + +function isUtcInstant(value: unknown): value is string { + if (typeof value !== 'string' || !UTC_INSTANT.test(value)) return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) + && new Date(parsed).toISOString() === (value.includes('.') ? value : value.replace('Z', '.000Z')); +} function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } diff --git a/src/platform/expo/notifications/expoDriverNotificationService.test.ts b/src/platform/expo/notifications/expoDriverNotificationService.test.ts new file mode 100644 index 0000000..38fd2bc --- /dev/null +++ b/src/platform/expo/notifications/expoDriverNotificationService.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, describe, it } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; + +import { registerDriverPushTokenWithCapability, revokeDriverPushToken } from '../../../api/dsvDriverPushToken'; +import { createDriverNotificationRecovery } from '../../../domain/notifications/driverNotificationRecovery'; +import * as notifications from '../../../domain/notifications/driverPushNotification'; + +type Service = typeof import('./expoDriverNotificationService'); +const ORIGINAL_FETCH = globalThis.fetch; +const flush = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { resolve = accept; }); + return { promise, resolve }; +} + +// Executes the production Expo adapter with native listeners and storage replaced by synthetic interfaces. +function nativeServiceHarness() { + const stored = new Map(); + const receipts = new Set<(event: unknown) => void>(); + const tokenListeners = new Set<(token: { data: string }) => void>(); + const capabilities: { accessToken: string; tokenId: string; installationId: string }[] = []; + let token = 'fcm-A'; + const storage = { + getItem: async (key: string) => stored.get(key) ?? null, + setItem: async (key: string, value: string) => { stored.set(key, value); }, + removeItem: async (key: string) => { stored.delete(key); }, + }; + const dependencies: Record = { + '@react-native-async-storage/async-storage': { __esModule: true, default: storage }, + 'expo-application': { + applicationId: 'com.evnsolution.clever.driver', nativeApplicationVersion: '0.1.3', getAndroidId: () => 'installation-A', + }, + 'react-native': { Platform: { OS: 'android' } }, + 'expo-notifications': { + AndroidImportance: { HIGH: 4 }, + setNotificationChannelAsync: async () => undefined, + getPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + requestPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + getDevicePushTokenAsync: async () => ({ data: token }), + addNotificationReceivedListener: (callback: (event: unknown) => void) => { receipts.add(callback); return { remove: () => receipts.delete(callback) }; }, + addPushTokenListener: (callback: (next: { data: string }) => void) => { tokenListeners.add(callback); return { remove: () => tokenListeners.delete(callback) }; }, + setNotificationHandler: () => undefined, + clearLastNotificationResponseAsync: async () => undefined, + }, + '../../../api/dsvDriverOperational': { + registerDriverOperationalCapability: async (accessToken: string, input: { tokenId: string; installationId: string }) => { capabilities.push({ accessToken, ...input }); }, + }, + '../../../api/dsvDriverPushToken': { registerDriverPushTokenWithCapability, revokeDriverPushToken }, + '../../../config/driverOperational': { DRIVER_OPERATIONAL_ENABLED: true }, + '../../../domain/notifications/driverPushNotification': notifications, + '../../../domain/notifications/driverNotificationRecovery': { createDriverNotificationRecovery }, + }; + const source = readFileSync(new URL('./expoDriverNotificationService.ts', import.meta.url), 'utf8'); + const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }); + const module = { exports: {} }; + runInNewContext(outputText, { module, exports: module.exports, Date, Intl, Promise, + require: (name: string) => { assert.ok(name in dependencies, `Unexpected dependency ${name}`); return dependencies[name]; } }); + return { + service: module.exports as Service, + token: (value: string) => { token = value; }, + renewedToken: (value: string) => { token = value; tokenListeners.forEach((callback) => callback({ data: value })); }, + storedToken: () => stored.get('driver.push.device-token'), + receipt: () => receipts.forEach((callback) => callback({ request: { identifier: 'provider-id', content: { data: { type: 'driver_route_changed', routePlanId: 'route-A' } } } })), + capabilities, + }; +} + +describe('Production Expo push ownership and revocation ordering', () => { + afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; }); + + it('ignores a late account-A revoke after account-B registration and retains B receipt and renewal capability', async () => { + const calls: { method: string; token: string; account: string | null }[] = []; + let registration = 0; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init?.body as string); + calls.push({ method: init?.method ?? '', token: body.devicePushToken, account: (init?.headers as Headers).get('Authorization') }); + return new Response(JSON.stringify({ data: { pushToken: { id: `31200000-0000-4000-8000-${String(++registration).padStart(12, '0')}`, status: 'ACTIVE' } }, error: null })); + }; + const h = nativeServiceHarness(); + const stopA = h.service.subscribeToExpoDriverPushNotifications('access-A', () => undefined); + await h.service.registerExpoDriverPushNotifications('access-A'); stopA(); + h.token('fcm-B'); + let receivedB = 0; + const stopB = h.service.subscribeToExpoDriverPushNotifications('access-B', () => { receivedB += 1; }); + await h.service.registerExpoDriverPushNotifications('access-B'); + await h.service.revokeExpoDriverPushNotifications('access-A'); + h.receipt(); + assert.equal(receivedB, 1); + assert.equal(h.storedToken(), 'fcm-B'); + assert.equal(calls.filter(({ method }) => method === 'DELETE').length, 0); + h.renewedToken('fcm-B-renewed'); await flush(); await flush(); + assert.equal(h.storedToken(), 'fcm-B-renewed'); + assert.equal(h.capabilities.at(-1)?.accessToken, 'access-B'); + assert.equal(h.capabilities.at(-1)?.installationId, 'installation-A'); + stopB(); + }); + + it('finishes A revocation before registering B and never removes B token storage', async () => { + const deletion = deferred(); + const order: string[] = []; + let registration = 0; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init?.body as string); + order.push(`${init?.method}:${body.devicePushToken}`); + if (init?.method === 'DELETE') return deletion.promise; + return new Response(JSON.stringify({ data: { pushToken: { id: `31200000-0000-4000-8000-${String(++registration).padStart(12, '0')}`, status: 'ACTIVE' } }, error: null })); + }; + const h = nativeServiceHarness(); + const stopA = h.service.subscribeToExpoDriverPushNotifications('access-A', () => undefined); + await h.service.registerExpoDriverPushNotifications('access-A'); + const revocation = h.service.revokeExpoDriverPushNotifications('access-A'); + stopA(); h.token('fcm-B'); + let receipts = 0; + const stopB = h.service.subscribeToExpoDriverPushNotifications('access-B', () => { receipts += 1; }); + const registerB = h.service.registerExpoDriverPushNotifications('access-B'); + await flush(); + assert.deepEqual(order, ['PUT:fcm-A', 'DELETE:fcm-A']); + deletion.resolve(new Response(JSON.stringify({ data: { revoked: true }, error: null }))); + await revocation; await registerB; + assert.deepEqual(order, ['PUT:fcm-A', 'DELETE:fcm-A', 'PUT:fcm-B']); + assert.equal(h.storedToken(), 'fcm-B'); + h.receipt(); assert.equal(receipts, 1); + stopB(); + }); + + it('does not revoke the renewed access-token registration with the previous access token', async () => { + let deletes = 0; + globalThis.fetch = async (_url, init) => { + if (init?.method === 'DELETE') deletes += 1; + return new Response(JSON.stringify({ data: { pushToken: { id: '31200000-0000-4000-8000-000000000001', status: 'ACTIVE' } }, error: null })); + }; + const h = nativeServiceHarness(); + const stopOld = h.service.subscribeToExpoDriverPushNotifications('access-A', () => undefined); + await h.service.registerExpoDriverPushNotifications('access-A'); stopOld(); + const stopRenewed = h.service.subscribeToExpoDriverPushNotifications('access-A-renewed', () => undefined); + await h.service.registerExpoDriverPushNotifications('access-A-renewed'); + await h.service.revokeExpoDriverPushNotifications('access-A'); + assert.equal(deletes, 0); + assert.equal(h.storedToken(), 'fcm-A'); + stopRenewed(); + }); +}); diff --git a/src/platform/expo/notifications/expoDriverNotificationService.ts b/src/platform/expo/notifications/expoDriverNotificationService.ts index 008565a..3bec2f5 100644 --- a/src/platform/expo/notifications/expoDriverNotificationService.ts +++ b/src/platform/expo/notifications/expoDriverNotificationService.ts @@ -3,126 +3,163 @@ import * as Application from 'expo-application'; import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; +import { registerDriverOperationalCapability } from '../../../api/dsvDriverOperational'; +import { registerDriverPushTokenWithCapability, revokeDriverPushToken } from '../../../api/dsvDriverPushToken'; +import { DRIVER_OPERATIONAL_ENABLED } from '../../../config/driverOperational'; +import { createDriverNotificationRecovery } from '../../../domain/notifications/driverNotificationRecovery'; import { - registerDriverPushToken, - revokeDriverPushToken, -} from '../../../api/dsvDriverPushToken'; -import { + classifyDriverNotificationClick, parseDriverPushNotification, + type DriverNotificationClick, type DriverPushNotification, } from '../../../domain/notifications/driverPushNotification'; const DRIVER_ANDROID_APP_ID = 'com.evnsolution.clever.driver'; const ROUTE_UPDATES_CHANNEL_ID = 'route-updates'; const STORED_PUSH_TOKEN_KEY = 'driver.push.device-token'; -const LAST_HANDLED_NOTIFICATION_KEY = 'driver.push.last-handled-notification'; +let pushSession: { accessToken: string; generation: number } | null = null; +let pushGeneration = 0; +let pushOwnerAccessToken: string | null = null; +let registrationTail: Promise = Promise.resolve(); + +export type DriverPushRegistrationState = { + status: 'registered' | 'permission-denied' | 'unsupported-device' | 'token-unavailable' | 'registration-error'; + canAskAgain?: boolean; +}; +export function createExpoDriverNotificationRecovery() { + return createDriverNotificationRecovery(AsyncStorage); +} export function configureExpoDriverPushNotifications(): void { Notifications.setNotificationHandler({ handleNotification: async () => ({ - shouldPlaySound: true, - shouldSetBadge: false, - shouldShowBanner: true, - shouldShowList: true, + shouldPlaySound: true, shouldSetBadge: false, shouldShowBanner: true, shouldShowList: true, }), }); - if (Platform.OS === 'android') void ensureAndroidNotificationChannel(); + if (isSupportedAndroid()) void ensureAndroidNotificationChannel().catch(() => undefined); } -export async function registerExpoDriverPushNotifications( - accessToken: string, -): Promise { - if (Platform.OS !== 'android' || Application.applicationId !== DRIVER_ANDROID_APP_ID) { - return; - } +export async function registerExpoDriverPushNotifications(accessToken: string): Promise { + if (!isSupportedAndroid()) return { status: 'unsupported-device' }; + const session = beginPushSession(accessToken); await ensureAndroidNotificationChannel(); let permissions = await Notifications.getPermissionsAsync(); - if (!permissions.granted) { - permissions = await Notifications.requestPermissionsAsync(); - } - if (!permissions.granted) return; - + if (!permissions.granted && permissions.canAskAgain) permissions = await Notifications.requestPermissionsAsync(); + if (!permissions.granted) return { status: 'permission-denied', canAskAgain: permissions.canAskAgain }; + if (!isCurrentSession(session)) return { status: 'registration-error' }; const token = await Notifications.getDevicePushTokenAsync(); - if (typeof token.data !== 'string' || token.data.trim() === '') return; - await registerToken(accessToken, token.data); + if (typeof token.data !== 'string' || token.data.trim() === '') return { status: 'token-unavailable' }; + await registerToken(session, token.data); + return { status: isCurrentSession(session) ? 'registered' : 'registration-error' }; } -export async function revokeExpoDriverPushNotifications( - accessToken: string, -): Promise { - const token = await AsyncStorage.getItem(STORED_PUSH_TOKEN_KEY); - if (token === null) return; - await revokeDriverPushToken(accessToken, token); - await AsyncStorage.removeItem(STORED_PUSH_TOKEN_KEY); +export function revokeExpoDriverPushNotifications(accessToken: string): Promise { + // An old account or access-token generation must not invalidate the current registration. + if (pushOwnerAccessToken !== null && pushOwnerAccessToken !== accessToken) return Promise.resolve(); + pushGeneration += 1; + pushSession = null; + pushOwnerAccessToken = null; + // Append the complete revocation now. A later account's PUT must wait for this DELETE. + const operation = registrationTail.catch(() => undefined).then(async () => { + const token = await AsyncStorage.getItem(STORED_PUSH_TOKEN_KEY); + if (token === null) return; + await revokeDriverPushToken(accessToken, token); + if (await AsyncStorage.getItem(STORED_PUSH_TOKEN_KEY) === token) { + await AsyncStorage.removeItem(STORED_PUSH_TOKEN_KEY); + } + }); + registrationTail = operation; + return operation; +} + +/** Subscribed before authentication. Receipt is separate from a user's notification click. */ +export function subscribeToExpoDriverNotificationClicks(onClick: (notification: DriverNotificationClick) => void): () => void { + if (!isSupportedAndroid()) return () => undefined; + let active = true; + const emit = (event: Notifications.NotificationResponse) => { + if (!active) return; + onClick(classifyDriverNotificationClick( + event.notification.request.identifier, + event.notification.request.content.data ?? {}, + )); + }; + const subscription = Notifications.addNotificationResponseReceivedListener(emit); + void Notifications.getLastNotificationResponseAsync().then((event) => { + if (event !== null) emit(event); + }).catch(() => undefined); + return () => { active = false; subscription.remove(); }; } +export async function clearExpoDriverNotificationResponse(): Promise { + if (isSupportedAndroid()) await Notifications.clearLastNotificationResponseAsync(); +} + +/** Receipts refresh the inbox only. They never resolve a destination or acknowledge OPENED. */ export function subscribeToExpoDriverPushNotifications( accessToken: string, onNotification: (notification: DriverPushNotification) => void, + onRegistrationState?: (state: DriverPushRegistrationState) => void, ): () => void { - if (Platform.OS !== 'android' || Application.applicationId !== DRIVER_ANDROID_APP_ID) { - return () => undefined; - } + if (!isSupportedAndroid()) return () => undefined; + const session = beginPushSession(accessToken); + let active = true; const received = Notifications.addNotificationReceivedListener((notification) => { - const parsed = parseNotification(notification); + if (!active || !isCurrentSession(session)) return; + const parsed = parseDriverPushNotification(notification.request.identifier, notification.request.content.data ?? {}); if (parsed !== null) onNotification(parsed); }); - const response = Notifications.addNotificationResponseReceivedListener((event) => { - void handleNotificationResponse(event, onNotification); - }); const token = Notifications.addPushTokenListener((nextToken) => { - if (typeof nextToken.data === 'string') { - void registerToken(accessToken, nextToken.data).catch(() => undefined); - } + if (typeof nextToken.data !== 'string' || !active || !isCurrentSession(session)) return; + void registerToken(session, nextToken.data).then(() => { + if (active && isCurrentSession(session)) onRegistrationState?.({ status: 'registered' }); + }).catch(() => { + if (active && isCurrentSession(session)) onRegistrationState?.({ status: 'registration-error' }); + }); }); - void Notifications.getLastNotificationResponseAsync() - .then((event) => { - if (event !== null) return handleNotificationResponse(event, onNotification); - }) - .catch(() => undefined); - return () => { + active = false; received.remove(); - response.remove(); token.remove(); + if (isCurrentSession(session)) { pushGeneration += 1; pushSession = null; } }; } -async function handleNotificationResponse( - response: Notifications.NotificationResponse, - onNotification: (notification: DriverPushNotification) => void, -): Promise { - const parsed = parseNotification(response.notification); - if (parsed === null) return; - const lastHandled = await AsyncStorage.getItem(LAST_HANDLED_NOTIFICATION_KEY); - if (lastHandled === parsed.notificationId) return; - await AsyncStorage.setItem(LAST_HANDLED_NOTIFICATION_KEY, parsed.notificationId); - onNotification(parsed); +function registerToken(session: NonNullable, devicePushToken: string): Promise { + const operation = registrationTail.catch(() => undefined).then(async () => { + if (!isCurrentSession(session)) return; + const permissions = await Notifications.getPermissionsAsync(); + if (!permissions.granted || !isCurrentSession(session)) return; + const localeAndTimezone = Intl.DateTimeFormat().resolvedOptions(); + await registerDriverPushTokenWithCapability(session.accessToken, { + appId: DRIVER_ANDROID_APP_ID, + appVersion: Application.nativeApplicationVersion ?? undefined, + deviceId: Application.getAndroidId(), + devicePushToken, + locale: localeAndTimezone.locale, + platform: 'android', + timezone: localeAndTimezone.timeZone, + }, { + isCurrent: () => isCurrentSession(session), + operationalEnabled: DRIVER_OPERATIONAL_ENABLED, + registerCapability: registerDriverOperationalCapability, + onTokenRegistered: () => AsyncStorage.setItem(STORED_PUSH_TOKEN_KEY, devicePushToken), + }); + }); + registrationTail = operation; + return operation; } - -function parseNotification( - notification: Notifications.Notification, -): DriverPushNotification | null { - return parseDriverPushNotification( - notification.request.identifier, - notification.request.content.data ?? {}, - ); +function beginPushSession(accessToken: string): NonNullable { + if (pushSession?.accessToken === accessToken) return pushSession; + pushOwnerAccessToken = accessToken; + pushSession = { accessToken, generation: ++pushGeneration }; + return pushSession; } - -async function registerToken(accessToken: string, devicePushToken: string): Promise { - const localeAndTimezone = Intl.DateTimeFormat().resolvedOptions(); - await registerDriverPushToken(accessToken, { - appId: DRIVER_ANDROID_APP_ID, - appVersion: Application.nativeApplicationVersion ?? undefined, - deviceId: Application.getAndroidId(), - devicePushToken, - locale: localeAndTimezone.locale, - platform: 'android', - timezone: localeAndTimezone.timeZone, - }); - await AsyncStorage.setItem(STORED_PUSH_TOKEN_KEY, devicePushToken); +function isCurrentSession(session: NonNullable): boolean { + return pushSession?.generation === session.generation; +} +function isSupportedAndroid(): boolean { + return Platform.OS === 'android' && Application.applicationId === DRIVER_ANDROID_APP_ID; } - async function ensureAndroidNotificationChannel(): Promise { await Notifications.setNotificationChannelAsync(ROUTE_UPDATES_CHANNEL_ID, { importance: Notifications.AndroidImportance.HIGH, diff --git a/src/platform/expo/storage/driverCommandStore.ts b/src/platform/expo/storage/driverCommandStore.ts new file mode 100644 index 0000000..5c65a9e --- /dev/null +++ b/src/platform/expo/storage/driverCommandStore.ts @@ -0,0 +1,35 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + DriverCommandQueueError, + parseStoredDriverCommands, + type DriverCommandStore, +} from '../../../domain/delivery/driverCommandQueue'; + +const STORAGE_KEY = 'clever-driver.operational-commands.v1'; +let storageWork: Promise = Promise.resolve(); + +function serializeStorage(operation: () => Promise): Promise { + const result = storageWork.then(operation); + storageWork = result.then(() => undefined, () => undefined); + return result; +} + +/** Contains account-scoped command identities/fences only; never authentication tokens or images. */ +export function createDriverCommandStore(): DriverCommandStore { + return { + load() { + return serializeStorage(async () => { + const saved = await AsyncStorage.getItem(STORAGE_KEY); + if (saved === null) return []; + let parsed: unknown; + try { parsed = JSON.parse(saved); } catch { throw new DriverCommandQueueError('STORAGE_INVALID'); } + return parseStoredDriverCommands(parsed); + }); + }, + save(commands) { + const serialized = JSON.stringify(parseStoredDriverCommands(commands)); + return serializeStorage(() => AsyncStorage.setItem(STORAGE_KEY, serialized)); + }, + }; +} diff --git a/src/ui/driver/DriverDeliveryException.tsx b/src/ui/driver/DriverDeliveryException.tsx new file mode 100644 index 0000000..c7c07c4 --- /dev/null +++ b/src/ui/driver/DriverDeliveryException.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; + +export type DriverDeliveryExceptionReason = { + code: string; + label: string; + requiresExplanation?: boolean; +}; + +export function DriverDeliveryException({ + destinationName, + reasons, + onSubmit, +}: { + destinationName: string; + reasons: readonly DriverDeliveryExceptionReason[]; + onSubmit(reasonCode: string, explanation?: string): Promise; +}) { + const [isOpen, setIsOpen] = useState(false); + const [reasonCode, setReasonCode] = useState(); + const [explanation, setExplanation] = useState(''); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(); + const [isConfirmed, setIsConfirmed] = useState(false); + const sendingRef = useRef(false); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + const selected = reasons.find((reason) => reason.code === reasonCode); + const canSubmit = selected !== undefined && (!selected.requiresExplanation || explanation.trim() !== ''); + + async function submit() { + if (sendingRef.current || !canSubmit || selected === undefined || isConfirmed) return; + sendingRef.current = true; setIsSending(true); setError(undefined); + try { + await onSubmit(selected.code, explanation.trim() || undefined); + if (mountedRef.current) setIsConfirmed(true); + } catch (failure) { + if (mountedRef.current) setError(failure instanceof Error ? failure.message : '미배송 보고를 전송하지 못했습니다.'); + } finally { + sendingRef.current = false; + if (mountedRef.current) setIsSending(false); + } + } + + return ( + + setIsOpen(true)}> + 미배송 보고 + + {reasons.length === 0 ? 보고 사유 승인 후 사용할 수 있습니다. : null} + {isOpen ? ( + { if (!isSending) setIsOpen(false); }}> + + + 미배송 보고 + {destinationName} + 보고는 배송 완료 또는 배송 실패 처리가 아닙니다. + {isConfirmed ? 서버가 미배송 보고를 접수했습니다. : ( + <> + {reasons.map((reason) => ( + setReasonCode(reason.code)} style={styles.reason}> + {reasonCode === reason.code ? '●' : '○'} {reason.label} + + ))} + + {error ? {error} : null} + {isSending ? : null} + { void submit(); }} style={styles.submit}> + 보고 전송 + + + )} + setIsOpen(false)} style={styles.reason}> + 닫기 + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + entry: { paddingHorizontal: 16, paddingVertical: 8, gap: 4 }, + buttonText: { color: '#B54708', fontWeight: '700' }, + help: { color: '#667085', fontSize: 12 }, + backdrop: { flex: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 48, backgroundColor: 'rgba(15,23,42,0.56)' }, + card: { flexGrow: 0, borderRadius: 20, backgroundColor: '#FFFFFF' }, + content: { padding: 20, gap: 12 }, + title: { fontSize: 20, fontWeight: '700' }, + reason: { paddingVertical: 12 }, + input: { minHeight: 100, borderWidth: 1, borderColor: '#D0D5DD', borderRadius: 8, padding: 12, textAlignVertical: 'top' }, + error: { color: '#B42318' }, + submit: { backgroundColor: '#0B57D0', padding: 12, borderRadius: 8, alignItems: 'center' }, + submitText: { color: '#FFFFFF', fontWeight: '700' }, +}); diff --git a/src/ui/driver/DriverOperationalInbox.tsx b/src/ui/driver/DriverOperationalInbox.tsx new file mode 100644 index 0000000..3fc6253 --- /dev/null +++ b/src/ui/driver/DriverOperationalInbox.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { + acknowledgeDriverOperationalNotification, + loadDriverOperationalInbox, + type DriverOperationalInboxItem, +} from '../../api/dsvDriverOperational'; + +export function DriverOperationalInbox({ accessToken, onClose, onOpen }: { + accessToken: string; + onClose(): void; + onOpen(notificationId: string): void; +}) { + const [items, setItems] = useState([]); + const [cursor, setCursor] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const [request, setRequest] = useState<{ cursor?: string; sequence: number }>({ sequence: 0 }); + + useEffect(() => { + let active = true; + void Promise.resolve().then(() => { if (active) setLoading(true); }); + void loadDriverOperationalInbox(accessToken, request.cursor) + .then((page) => { + if (!active) return; + setItems((previous) => request.cursor === undefined ? page.items : [ + ...previous, ...page.items.filter((item) => !previous.some((old) => old.id === item.id)), + ]); + setCursor(page.nextCursor); + setError(undefined); + }) + .catch(() => { if (active) setError('알림을 불러오지 못했습니다. 다시 시도해 주세요.'); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [accessToken, request]); + + async function markRead(id: string) { + try { + const ack = await acknowledgeDriverOperationalNotification(accessToken, id, 'READ'); + setItems((previous) => previous.map((item) => item.id === id ? { ...item, ackedAt: ack.ackedAt } : item)); + } catch { + setError('읽음 처리를 저장하지 못했습니다. 다시 시도해 주세요.'); + } + } + + return + + 알림함 +