From 9ccbc5a611d99e93a8993145c7c9d636608be022 Mon Sep 17 00:00:00 2001 From: OziinG Date: Fri, 2 Oct 2026 15:44:08 +0900 Subject: [PATCH] Keep driver health evidence accurate across direct sends and storage failures Constraint: Preserve production delivery data, GPS persistence truth and the existing diagnostic isolation contract. Confidence: high Scope-risk: moderate Directive: Deploy the additive storage diagnostic contract before the matching Routes release. Tested: API lint/typecheck/build and 2972 tests; Route Ops lint/typecheck/build and 194 tests; diagnostic/privacy regressions; diff and secret checks. Not-tested: 211 environment-gated API tests; exact Node 22 CI and physical candidate verification follow the source commit. --- .../docs/api/driver-runtime-diagnostics.md | 48 +++++++- .../driver-runtime-diagnostics.contract.ts | 8 +- .../driver-runtime-diagnostics.projection.ts | 55 ++++++--- .../delivery-api/src/routes/privacy.routes.ts | 10 +- ...river-runtime-diagnostics.contract.test.ts | 107 +++++++++++++++++- .../delivery-api/tests/privacy.routes.test.ts | 12 ++ 6 files changed, 216 insertions(+), 24 deletions(-) diff --git a/apps/delivery-api/docs/api/driver-runtime-diagnostics.md b/apps/delivery-api/docs/api/driver-runtime-diagnostics.md index ba576c17..27f9e1f9 100644 --- a/apps/delivery-api/docs/api/driver-runtime-diagnostics.md +++ b/apps/delivery-api/docs/api/driver-runtime-diagnostics.md @@ -71,6 +71,18 @@ observation grace result in UNKNOWN states rather than invented failure times. Absence means `SIGNAL_ABSENT_UNKNOWN`, not proof of app termination, network loss, or operating-system restrictions. Classification uses versioned server constants. +A current direct GPS transmission can be `HEALTHY` when callback, collection, +send-attempt, client-ACK, empty observed queue, runtime state observations, and +server contact are all fresh and there are no active blockers. Direct success +does not require a local queue write, so `lastGpsPersistedAt` may truthfully remain +`null`; the server does not synthesize it. An older valid persistence timestamp +also does not override a fresh direct send and ACK. A missing or stale ACK, a non-empty or +stale queue, a storage/processing/transport blocker, stale runtime state, or absent +signal prevents `HEALTHY`. Server-applied business-event evidence, authentication +or route blockers, failed/rejected server attempts, and stale/future evidence keep +their existing higher-priority diagnoses. Historical replay never replaces the +newer live snapshot. + Event joins require company, driver, route, and the supplied correlation IDs. UUID-validated client `X-Request-Id` is `transportRequestId` in the event attempt table; the server's unique `requestId` remains separate. APPLIED/DUPLICATE evidence @@ -89,11 +101,41 @@ constraint/index names found in the production backup; their definitions and historical migrations are unchanged. This avoids unrelated naming drift during the migration verification. +The public Routes privacy notice describes the same boundary: automatic technical +diagnostics are linked to the signed-in account and an installation-derived hash, +and authorized EV&Solution and tenant delivery operators may use tenant-scoped +records for support and service reliability. Diagnostic payloads exclude tokens, +PINs, names, phone numbers, addresses, raw coordinates, proof images or note +contents, and arbitrary error text. Server diagnostic records and state use a +30-day retention basis; installation linkage is eligible for cleanup after 30 days +without contact once related records, state, and credentials are gone. The mobile +diagnostic pending and quarantine stores each use a 1,000-record and seven-day +bound during app storage/recovery work. Those mobile limits do not delete queued +business events or proof photos. + +`STORAGE_READ_FAILED` and `STORAGE_WRITE_FAILED` are distinct allowlisted reasons; +both remain storage-stage blockers, while `STORAGE_OPERATION_TIMEOUT` remains the +bounded-operation timeout reason. A processing, storage, or transport blocker is +`GPS_POST_COLLECTION_BLOCKED` only when its safe operation identifier or reason +correlates it to the GPS pipeline. Other current runtime work, such as completion +assistance persistence, is `RUNTIME_OPERATION_BLOCKED`; it does not imply that raw +GPS collection or transmission stopped. A fresh higher-priority blocker is evaluated +before unrelated stale blocker history. Diagnosis states are additive operational +labels; admin consumers must preserve and display an unknown future state rather +than treating it as `HEALTHY`. + +Completion-assistance storage correlation accepts only +`completion-assistance-read:`, `completion-assistance-write:`, and +`completion-assistance-remove:` with a strict UUID suffix. Arbitrary +suffix text remains an invalid record and is never persisted as diagnostic detail. + Deployment order: server migration and immutable API image; endpoint/runtime verification; then the separately validated Routes app release. No alerting is -enabled by this server change. The app currently ignores `rejectedDiagnostics`: -before its release, it must quarantine permanent rejections so a poison record -cannot block its outbox, and invoke revocation on explicit logout. Local token +enabled by this server change. Older clients may ignore `rejectedDiagnostics`. +The separately built Routes 1.3.4 (40) release candidate acknowledges accepted +or duplicate IDs and atomically quarantines recognized permanent rejections so a +poison record cannot block its outbox; source, artifact, and device installation +remain separate evidence. It also invokes revocation on explicit logout. Local token deletion alone does not revoke the server credential. Route reassignment replay without independently verified historical ownership remains rejected rather than trusting the payload's claimed driver. diff --git a/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.contract.ts b/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.contract.ts index 719bcf45..0fc71d79 100644 --- a/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.contract.ts +++ b/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.contract.ts @@ -36,6 +36,7 @@ export const DRIVER_DIAGNOSTIC_REASON_CODES = [ 'ROUTE_NOT_IN_PROGRESS', 'SESSION_MISMATCH', 'STORAGE_OPERATION_TIMEOUT', + 'STORAGE_READ_FAILED', 'STORAGE_WRITE_FAILED' ] as const; @@ -135,6 +136,7 @@ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3} const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; const sessionGenerationPattern = /^(?:[0-9]{1,20}|[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/iu; const generatedClientEventIdPattern = /^(?:(?:route-started|route-completed|route-released|pickup-completed|location-updated|stop-delivered|stop-failed)-[a-z0-9]{1,32}|stop-arrived-[0-9a-f-]{36}-[a-z0-9]{1,32}|continuous-location-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z-[0-9]{1,6}|[0-9A-HJKMNP-TV-Z]{20,26})$/u; +const completionAssistanceClientEventIdPattern = /^completion-assistance-(?:read|write|remove):[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; const versionPattern = /^[A-Za-z0-9._+()-]{1,64}$/u; const hashPattern = /^[a-fA-F0-9]{32,128}$/u; const reasonCodes = new Set(DRIVER_DIAGNOSTIC_REASON_CODES); @@ -183,7 +185,11 @@ function sanitizeIdentifiers(value: unknown): DriverDiagnosticIdentifiers | null const clientEventId = !hasClientEventId ? undefined : typeof value.clientEventId === 'string' - && (uuidPattern.test(value.clientEventId) || generatedClientEventIdPattern.test(value.clientEventId)) + && ( + uuidPattern.test(value.clientEventId) + || generatedClientEventIdPattern.test(value.clientEventId) + || completionAssistanceClientEventIdPattern.test(value.clientEventId) + ) ? value.clientEventId : null; const requestId = !hasRequestId ? undefined : safeUuid(value.requestId) ?? null; diff --git a/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.projection.ts b/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.projection.ts index 66de2cb2..8fa5602c 100644 --- a/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.projection.ts +++ b/apps/delivery-api/src/modules/driver/driver-runtime-diagnostics.projection.ts @@ -19,6 +19,7 @@ export type DriverRuntimeDiagnosticState = | 'GPS_COLLECTION_STOPPED' | 'GPS_POST_COLLECTION_BLOCKED' | 'HEALTHY' + | 'RUNTIME_OPERATION_BLOCKED' | 'SERVER_APPLIED_CLIENT_ACK_UNKNOWN' | 'SERVER_RECEIVED_NOT_APPLIED' | 'SIGNAL_ABSENT_UNKNOWN' @@ -135,6 +136,15 @@ function observedAt(blocker: DriverDiagnosticBlocker): Date { return new Date(blocker.since); } +function isGpsCorrelated(blocker: DriverDiagnosticBlocker): boolean { + if ( + blocker.stage === 'PROCESSING' + && (blocker.reason === 'LOCATION_PROCESSING_FAILED' || blocker.reason === 'LOCATION_PIPELINE_TIMEOUT') + ) return true; + return blocker.clientEventId !== undefined + && /^(?:continuous-location|location-updated)(?:[-:.]|$)/u.test(blocker.clientEventId); +} + export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInput): DriverRuntimeDiagnosticProjection { const nowMs = input.now.getTime(); if (input.lastContactAt === null) { @@ -157,17 +167,15 @@ export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInpu } const blockers = snapshot.blockers ?? []; - const staleBlocker = blockers.find((blocker) => { + const currentBlockers = blockers.filter((blocker) => { const lastObservedAt = parseTimestamp(blocker.lastObservedAt); const since = parseTimestamp(blocker.since); - return !isFresh(nowMs, lastObservedAt) || isFuture(nowMs, since); + return isFresh(nowMs, lastObservedAt) && !isFuture(nowMs, since); }); - if (staleBlocker !== undefined) { - return projection({ blocker: staleBlocker, observedAt: parseTimestamp(staleBlocker.lastObservedAt), state: 'UNKNOWN_STALE_EVIDENCE' }); - } + const staleBlocker = blockers.find((blocker) => !currentBlockers.includes(blocker)); const attempts = input.attempts ?? []; - for (const blocker of blockers) { + for (const blocker of currentBlockers) { const scopedAttempts = attemptsForBlocker(attempts, blocker); const successful = scopedAttempts.filter(({ receivedAt, status }) => ( Number.isFinite(receivedAt.getTime()) @@ -193,11 +201,11 @@ export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInpu } } - const authOrRoute = blockers.find(({ stage }) => stage === 'AUTH' || stage === 'ROUTE'); + const authOrRoute = currentBlockers.find(({ stage }) => stage === 'AUTH' || stage === 'ROUTE'); if (authOrRoute !== undefined) { return projection({ blocker: authOrRoute, observedAt: observedAt(authOrRoute), state: 'AUTH_OR_ROUTE_BLOCKED' }); } - const diagnosticStorage = blockers.find(({ reason }) => reason === 'DIAGNOSTIC_STORAGE_FAILED'); + const diagnosticStorage = currentBlockers.find(({ reason }) => reason === 'DIAGNOSTIC_STORAGE_FAILED'); if (diagnosticStorage !== undefined) { return projection({ blocker: diagnosticStorage, observedAt: observedAt(diagnosticStorage), state: 'DIAGNOSTIC_EVIDENCE_DEGRADED' }); } @@ -215,13 +223,22 @@ export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInpu const callbackIsFresh = isFresh(nowMs, callbackAt); const collectionIsFresh = isFresh(nowMs, collectedAt); - const postCollection = blockers.find(({ stage }) => ( + const postCollectionBlockers = currentBlockers.filter(({ stage }) => ( stage === 'PROCESSING' || stage === 'STORAGE' || stage === 'TRANSPORT' )); - if (postCollection !== undefined && callbackIsFresh && collectionIsFresh) { - return projection({ blocker: postCollection, observedAt: observedAt(postCollection), state: 'GPS_POST_COLLECTION_BLOCKED' }); + const gpsPostCollection = postCollectionBlockers.find(isGpsCorrelated); + if (gpsPostCollection !== undefined && callbackIsFresh && collectionIsFresh) { + return projection({ + blocker: gpsPostCollection, + observedAt: observedAt(gpsPostCollection), + state: 'GPS_POST_COLLECTION_BLOCKED' + }); + } + const runtimeOperation = postCollectionBlockers.find((blocker) => !isGpsCorrelated(blocker)); + if (runtimeOperation !== undefined) { + return projection({ blocker: runtimeOperation, observedAt: observedAt(runtimeOperation), state: 'RUNTIME_OPERATION_BLOCKED' }); } - const locationBlocker = blockers.find(({ stage }) => stage === 'LOCATION'); + const locationBlocker = currentBlockers.find(({ stage }) => stage === 'LOCATION'); if (snapshot.locationTaskExpected !== true) { return projection({ ...(locationBlocker === undefined ? {} : { blocker: locationBlocker }), @@ -288,13 +305,16 @@ export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInpu if (locationBlocker !== undefined) { return projection({ blocker: locationBlocker, observedAt: observedAt(locationBlocker), state: 'UNKNOWN_INSUFFICIENT_EVIDENCE' }); } + if (staleBlocker !== undefined) { + return projection({ blocker: staleBlocker, observedAt: parseTimestamp(staleBlocker.lastObservedAt), state: 'UNKNOWN_STALE_EVIDENCE' }); + } const queueAt = parseTimestamp(snapshot.businessQueue.observedAt); - const requiredCurrentEvidence = [persistedAt, sendAttemptAt, sendAcknowledgedAt, queueAt]; - if (requiredCurrentEvidence.some((at) => isFuture(nowMs, at))) { - return projection({ observedAt: requiredCurrentEvidence.find((at) => isFuture(nowMs, at)) ?? snapshotAt, state: 'UNKNOWN_STALE_EVIDENCE' }); + const directSendEvidence = [sendAttemptAt, sendAcknowledgedAt, queueAt]; + if (directSendEvidence.some((at) => isFuture(nowMs, at))) { + return projection({ observedAt: directSendEvidence.find((at) => isFuture(nowMs, at)) ?? snapshotAt, state: 'UNKNOWN_STALE_EVIDENCE' }); } - const staleCurrentEvidence = requiredCurrentEvidence.find((at) => at !== null && !isFresh(nowMs, at)); + const staleCurrentEvidence = directSendEvidence.find((at) => at !== null && !isFresh(nowMs, at)); if (staleCurrentEvidence !== undefined) { return projection({ observedAt: staleCurrentEvidence, state: 'UNKNOWN_STALE_EVIDENCE' }); } @@ -303,9 +323,10 @@ export function deriveDriverRuntimeDiagnostic(input: DriverRuntimeDiagnosticInpu && snapshot.locationTask === 'STARTED' && snapshot.network === 'ONLINE'; if ( - requiredCurrentEvidence.every((at) => isFresh(nowMs, at)) + directSendEvidence.every((at) => isFresh(nowMs, at)) && snapshot.businessQueue.queueDepth === 0 && stateAllowsHealthy + && blockers.length === 0 ) { const currentStateObservations = Object.values(snapshot.stateObservedAt).map(parseTimestamp); const missingStateObservation = currentStateObservations.some((at) => at === null); diff --git a/apps/delivery-api/src/routes/privacy.routes.ts b/apps/delivery-api/src/routes/privacy.routes.ts index 4b55874f..35f4d136 100644 --- a/apps/delivery-api/src/routes/privacy.routes.ts +++ b/apps/delivery-api/src/routes/privacy.routes.ts @@ -5,7 +5,7 @@ const PUBLIC_PRIVACY_URL = 'https://clever-route-api.cleversystem.ai/privacy'; const DRIVER_LAST_UPDATED = '2026-09-07'; const DRIVER_PRIVACY_URL = 'https://clever-route-api.cleversystem.ai/driver-app/privacy'; const DRIVER_SUPPORT_URL = 'https://clever-route-api.cleversystem.ai/driver-app/support'; -const ROUTES_APP_LAST_UPDATED = '2026-09-01'; +const ROUTES_APP_LAST_UPDATED = '2026-10-02'; const ROUTES_APP_PRIVACY_URL = 'https://clever-route-api.cleversystem.ai/routes-app/privacy'; const ROUTES_APP_SUPPORT_URL = 'https://clever-route-api.cleversystem.ai/routes-app/support'; const ROUTES_APP_ACCOUNT_DELETION_URL = 'https://clever-route-api.cleversystem.ai/routes-app/account-deletion'; @@ -373,6 +373,9 @@ function renderRoutesAppPrivacyPage(): string {
  • 알림과 기기: 푸시 토큰, 제한된 기기 식별자 또는 그 해시, 앱 버전과 플랫폼 정보를 배송 알림, 세션 보호, 중복 기기 감지와 장애 대응에 사용합니다.
  • 위치 처리는 배송원이 명시적으로 시작한 활성 배송 경로의 수행에 필요한 범위로 제한합니다. 위치 권한은 기기 설정에서 변경할 수 있지만, 권한을 끄면 실시간 경로 기능이 제한될 수 있습니다.

    +

    앱 동작 및 장애 진단

    +

    로그인한 기기에서 앱의 안정성 확인, 동기화 지연 및 오류 원인 분석과 지원을 위해 기술 진단 정보를 자동으로 처리합니다. 이 정보에는 앱·빌드 및 운영체제 버전, 앱 실행 상태, 네트워크와 위치 권한·서비스 상태, 위치 처리와 이벤트 전송 단계의 시각, 대기·재시도 상태 및 제한된 오류 사유가 포함됩니다. 진단은 활성 배송 경로가 없는 경우에도 동작할 수 있습니다. 별도의 진단 수집 해제 설정은 제공하지 않습니다.

    +

    진단은 앱 설치 시 생성한 임의 식별자의 해시와 로그인 계정을 연결하여 처리하며, 필요한 경우 배정 경로·배송 이벤트 식별자와 연계합니다. 이 식별자는 하드웨어 일련번호나 광고 식별자가 아니지만 계정에 연결될 수 있으므로 완전한 익명 정보는 아닙니다. 진단용 데이터 본문에는 원시 위치 좌표, 배송 증빙 사진·서명·메모 본문, 이름·전화번호·주소, 비밀번호·PIN·인증 토큰 또는 자유 입력 오류 본문을 포함하지 않습니다. 기존 배송 위치와 증빙 처리는 이 방침의 해당 항목에 따릅니다.

    @@ -389,6 +392,7 @@ function renderRoutesAppPrivacyPage(): string {
  • 설정된 지도, 지오코딩과 경로 계산 제공자는 주소 또는 좌표를 지도 표시와 경로 계산에 사용할 수 있습니다.
  • 배송원이 외부 길찾기를 선택하면 Google Maps 또는 Waze 같은 선택된 지도 앱으로 배송지 주소 또는 좌표를 전달합니다.
  • +

    기술 진단은 접근이 통제된 연결 서버에서 처리합니다. 당사와 해당 배송사업자의 접근 권한을 가진 담당 운영자가 해당 계정·기기·배송 범위의 진단을 장애 확인, 지원 및 서비스 안정성 개선을 위해 조회할 수 있습니다. 진단 정보를 광고 목적으로 사용하거나 판매하지 않습니다.

    개인정보를 광고 목적으로 판매하지 않습니다. 법령, 이용자 보호 또는 서비스 제공에 필요한 경우를 제외하고 제3자에게 제공하지 않습니다.

    @@ -396,6 +400,10 @@ function renderRoutesAppPrivacyPage(): string {

    5. 보관, 삭제와 예외 / Retention and deletion

    실시간 위치 이벤트의 좌표는 운영 목적이 끝난 뒤 최소화하며, 증빙 사진은 기본 365일 보관 정책에 따라 정리합니다. 경로·정차 상태, 처리 시각과 비식별 운영 기록은 배송 이력, 분쟁, 보안, 계약 또는 법적 의무에 필요한 기간 동안 분리 보관할 수 있습니다.

    해결된 순서 이벤트 재시도 증거는 기본 90일 보관 후 정리합니다. 미해결 또는 조정이 필요한 기록은 해결될 때까지 제한적으로 보관할 수 있습니다.

    +

    기술 진단의 별도 보관 기준

    +

    서버의 진단 기록과 상태 정보는 수신 후 30일을 보관 기준으로 하며, 만료된 정보는 정리 작업에서 삭제합니다. 계정·설치 기기 연결 정보는 마지막 진단 접촉 후 30일이 지나고 관련 진단 기록·상태 및 자격정보가 남아 있지 않으면 정리합니다.

    +

    전송을 위한 기기 내 진단은 암호화 저장소에 보관하며, 계정별 전송 대기 기록과 전송이 거절되어 별도 보관되는 기록에 각각 최대 1,000건 제한을 적용합니다. 전송 대기 기록은 발생 시각, 별도 보관 기록은 별도 보관을 시작한 시각으로부터 7일을 정리 기준으로 사용합니다. 앱의 해당 저장·복구·전송 또는 별도 보관 작업 때 오래되거나 한도를 초과한 기록을 정리하며, 앱이 실행되지 않거나 저장소에 접근할 수 없는 동안에는 다음 관련 처리 시점까지 정리가 지연될 수 있습니다. 이 제한은 진단 기록에 적용되며 미전송 배송 이벤트와 사진을 삭제하는 기준이 아닙니다.

    +

    기술 진단 보관 기준은 배송 증빙 및 업무 이벤트 재시도 증거의 별도 보관 정책과 구분됩니다. 관련 개인정보의 열람·정정·처리 정지·삭제 문의와 계정 삭제 요청은 이 방침의 권리 및 문의 항목과 연결된 계정 삭제 절차를 이용할 수 있습니다.

    계정 및 개인정보 삭제 요청 절차, 처리 범위와 예외는 ${ROUTES_APP_ACCOUNT_DELETION_URL}에서 확인할 수 있습니다.

    diff --git a/apps/delivery-api/tests/driver-runtime-diagnostics.contract.test.ts b/apps/delivery-api/tests/driver-runtime-diagnostics.contract.test.ts index 5d20d6e5..dc01f5e8 100644 --- a/apps/delivery-api/tests/driver-runtime-diagnostics.contract.test.ts +++ b/apps/delivery-api/tests/driver-runtime-diagnostics.contract.test.ts @@ -182,6 +182,41 @@ describe('driver diagnostic contract parser', () => { blockers: [{ ...blocker(), clientEventId: 'raw arbitrary failure text' }] }))).toBeNull(); }); + + test.each(['read', 'write', 'remove'] as const)('round-trips the allowlisted completion assistance %s identifier', (operation) => { + const clientEventId = `completion-assistance-${operation}:${REQUEST_ID}`; + const payload = envelope(); + const record = payload.records[0]!; + const parsed = parseDriverDiagnosticEnvelope(envelope({ + records: [{ + ...record, + identifiers: { clientEventId }, + snapshot: snapshot({ blockers: [blocker({ clientEventId, reason: 'STORAGE_READ_FAILED', stage: 'STORAGE' })] }) + }] + })); + expect(parsed?.records[0]?.identifiers?.clientEventId).toBe(clientEventId); + expect(parsed?.records[0]?.snapshot.blockers?.[0]?.clientEventId).toBe(clientEventId); + }); + + test('accepts a strict version-7 UUID completion assistance identifier', () => { + const clientEventId = 'completion-assistance-read:018f47a2-4b5c-7def-8abc-0123456789ab'; + const record = envelope().records[0]!; + const parsed = parseDriverDiagnosticEnvelope(envelope({ + records: [{ ...record, identifiers: { clientEventId } }] + })); + expect(parsed?.records[0]?.identifiers?.clientEventId).toBe(clientEventId); + }); + + test('rejects arbitrary completion assistance identifier suffixes', () => { + const payload = envelope(); + const record = payload.records[0]!; + expect(parseDriverDiagnosticEnvelopeDetailed(envelope({ + records: [{ ...record, identifiers: { clientEventId: 'completion-assistance-read:customer-or-error-text' } }] + }))).toMatchObject({ + envelope: { records: [] }, + rejectedRecords: [{ code: 'INVALID_RECORD', diagnosticId: DIAGNOSTIC_ID }] + }); + }); }); describe('driver runtime diagnostic projection', () => { @@ -326,7 +361,7 @@ describe('driver runtime diagnostic projection', () => { }).state).toBe('UNKNOWN_STALE_EVIDENCE'); expect(classify({ snapshot: snapshot({ lastGpsPersistedAt: '2026-10-02T11:40:00.000Z' }) - }).state).toBe('UNKNOWN_STALE_EVIDENCE'); + }).state).toBe('HEALTHY'); }); test('does not invent collection stopped from an uncertain location observation failure', () => { @@ -357,14 +392,82 @@ describe('driver runtime diagnostic projection', () => { .toBe('GPS_COLLECTION_STOPPED'); }); - test('requires fresh evidence through persistence, send, ACK, and an empty observed queue for healthy', () => { + test('classifies a fresh direct GPS send as healthy without inventing local persistence', () => { expect(classify({}).state).toBe('HEALTHY'); expect(classify({ snapshot: snapshot({ lastGpsPersistedAt: null }) }).state) + .toBe('HEALTHY'); + expect(classify({ snapshot: snapshot({ lastGpsPersistedAt: '2026-10-02T11:40:00.000Z' }) }).state) + .toBe('HEALTHY'); + expect(classify({ snapshot: snapshot({ lastGpsSendAcknowledgedAt: null }) }).state) .toBe('UNKNOWN_INSUFFICIENT_EVIDENCE'); + expect(classify({ snapshot: snapshot({ lastGpsSendAcknowledgedAt: '2026-10-02T11:40:00.000Z' }) }).state) + .toBe('UNKNOWN_STALE_EVIDENCE'); expect(classify({ snapshot: snapshot({ businessQueue: { ...snapshot().businessQueue, queueDepth: 1 } }) }).state).toBe('UNKNOWN_INSUFFICIENT_EVIDENCE'); }); + + test('keeps blockers and missing signal ahead of direct-send healthy evidence', () => { + for (const [clientEventId, reason] of [ + [`completion-assistance-read:${REQUEST_ID}`, 'STORAGE_READ_FAILED'], + [`completion-assistance-write:${REQUEST_ID}`, 'STORAGE_WRITE_FAILED'] + ] as const) { + expect(classify({ + snapshot: snapshot({ + blockers: [blocker({ clientEventId, reason, stage: 'STORAGE' })], + lastGpsPersistedAt: null + }) + }).state).toBe('RUNTIME_OPERATION_BLOCKED'); + } + expect(classify({ + lastContactAt: new Date('2026-10-02T11:56:59.999Z'), + snapshot: snapshot({ lastGpsPersistedAt: null }) + }).state).toBe('SIGNAL_ABSENT_UNKNOWN'); + }); + + test('reports a non-GPS runtime blocker without requiring active GPS evidence', () => { + expect(classify({ + snapshot: snapshot({ + blockers: [blocker({ + clientEventId: `completion-assistance-read:${REQUEST_ID}`, + reason: 'STORAGE_READ_FAILED', + stage: 'STORAGE' + })], + lastGpsCallbackAt: null, + lastGpsCollectedAt: null, + locationTaskExpected: false + }) + }).state).toBe('RUNTIME_OPERATION_BLOCKED'); + }); + + test('prefers a fresh GPS-correlated blocker over an unrelated runtime blocker', () => { + const generic = blocker({ + clientEventId: `completion-assistance-write:${REQUEST_ID}`, + reason: 'STORAGE_WRITE_FAILED', + stage: 'STORAGE' + }); + const gps = blocker({ + clientEventId: 'continuous-location-2026-10-02T11:59:00.000Z-0', + reason: 'NETWORK_REQUEST_FAILED', + requestId: '10000000-0000-4000-8000-000000000099', + stage: 'TRANSPORT' + }); + expect(classify({ snapshot: snapshot({ blockers: [generic, gps] }) }).state) + .toBe('GPS_POST_COLLECTION_BLOCKED'); + }); + + test('does not let stale blocker history hide a fresh higher-priority blocker', () => { + const staleStorage = blocker({ + clientEventId: `completion-assistance-write:${REQUEST_ID}`, + lastObservedAt: '2026-10-02T11:40:00.000Z', + reason: 'STORAGE_WRITE_FAILED', + since: '2026-10-02T11:40:00.000Z', + stage: 'STORAGE' + }); + const freshAuth = blocker({ reason: 'AUTH_REFRESH_FAILED', stage: 'AUTH' }); + expect(classify({ snapshot: snapshot({ blockers: [staleStorage, freshAuth] }) }).state) + .toBe('AUTH_OR_ROUTE_BLOCKED'); + }); }); diff --git a/apps/delivery-api/tests/privacy.routes.test.ts b/apps/delivery-api/tests/privacy.routes.test.ts index 1f9127f8..4278bdf6 100644 --- a/apps/delivery-api/tests/privacy.routes.test.ts +++ b/apps/delivery-api/tests/privacy.routes.test.ts @@ -174,9 +174,21 @@ describe('privacy routes', () => { expect(response.body).toContain('경로 및 정차 활동'); expect(response.body).toContain('푸시 토큰'); expect(response.body).toContain('기기 식별자'); + expect(response.body).toContain('앱 동작 및 장애 진단'); + expect(response.body).toContain('활성 배송 경로가 없는 경우에도'); + expect(response.body).toContain('완전한 익명 정보는 아닙니다'); + expect(response.body).toContain('원시 위치 좌표'); + expect(response.body).toContain('비밀번호·PIN·인증 토큰'); + expect(response.body).toContain('해당 배송사업자의 접근 권한을 가진 담당 운영자'); + expect(response.body).toContain('서버의 진단 기록과 상태 정보는 수신 후 30일'); + expect(response.body).toContain('마지막 진단 접촉 후 30일'); + expect(response.body).toContain('각각 최대 1,000건'); + expect(response.body).toContain('7일을 정리 기준'); + expect(response.body).toContain('미전송 배송 이벤트와 사진을 삭제하는 기준이 아닙니다'); expect(response.body).toContain('Firebase Cloud Messaging'); expect(response.body).toContain('Google Maps 또는 Waze'); expect(response.body).toContain('/routes-app/account-deletion'); + expect(response.body).toContain('Last updated: 2026-10-02'); expect(response.body).not.toContain('CLEVER Driver 개인정보 처리방침'); } finally { await app.close();