From fd1586a67d7367c2d7155f3857c4bd19167ea355 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 00:54:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=8C=90=EB=A7=A4=EC=9E=90=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EB=B0=9C=EC=86=A1=20=EC=86=8C=EB=B9=84?= =?UTF-8?q?=EC=9E=90(Expo=20Push=20Service)=C2=B7=EC=98=81=EC=88=98?= =?UTF-8?q?=EC=A6=9D=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판매자 앱에 새 주문·새 문의 푸시를 보낸다. #507의 outbox 이벤트를 #510의 디바이스로 전달하는 마지막 조각. - expo-push.config: EXPO_PUSH_ENABLED(기본 false)·ACCESS_TOKEN·TIMEOUT_MS(기본 5000). 비표준 값은 기본값, 부팅 실패 조건 없음. app.module load 등록, README 환경 변수 표·infra/app.env.example에 키 3개. - src/global/expo-push: EXPO_PUSH_TRANSPORT 토큰·ExpoPushTransport 계약·fetch 기본 구현. send ≤100·getReceipts ≤1000(넘기면 throw), withTimeout, 비 2xx는 상태 코드를 담은 ExpoPushHttpError, 401/403은 ExpoPushAuthError, 200+errors·ticket 수 불일치도 거절. kakao-local과 같이 모듈 없이 NotificationModule.providers에 useValue로 등록. - SellerPushOutboxConsumer(@SubscribeOutbox order.submitted·conversation.buyer_message_sent, worker): enabled=false면 debug 로그 후 ack(이력 없음), payload 파싱 실패 throw, 매장 활성 디바이스 → claim(createFromEvent 방식 — 기존 행 제외 후 createMany, skipDuplicates 없음, unique가 최종 방어, PENDING만 전송) → 100개 배치 send → TICKET_OK(ticket_id·sent_at) / TICKET_ERROR(error_code), DeviceNotRegistered는 즉시 디바이스 비활성. 전송 예외는 던져 호스트 retry/DLQ, 인증 실패는 expo-push:auth 경보 뒤 throw. 메트릭 caquick_expo_push_sends_total{result}. - SellerPushReceiptScheduler(5분, worker 역할 가드·enabled 가드·동시 실행 플래그): 15분 지난 TICKET_OK 최대 300건 → RECEIPT_OK / RECEIPT_ERROR(+DeviceNotRegistered 비활성), 24h 넘게 영수증 없으면 RECEIPT_UNKNOWN, 그 전엔 유지. 실패는 warn + expo-push:receipts(인증 실패는 expo-push:auth), 행은 그대로. - SellerPushDeliveryRepository(claim·markTickets·listForReceipt·markReceipts), seller-push-messages.helper(주문 "새 주문" / "{상품} {n}개 · 픽업 M/d HH:mm"(KST), 문의 "새 문의" / preview, data kind·id, channelId default). - order·conversation 배럴에 payload 타입 export. - spec: config 19, transport 20, helper 6, delivery repo 9(FK 오류 전파 반증), consumer 14(재전달 send 0회·150→100+50· 배치 중간 실패 뒤 남은 50만 재전송·401 경보·enabled=false·payload 오류·구독 외 throw), scheduler 10(15분 미만 미조회· 24h UNKNOWN·api/ws 미실행·동시 실행 건너뜀), metrics 지표 표 1행. 가드 5개(PENDING 필터·enabled·running·15분·100개 상한)를 지우면 기대한 7건만 실패하는 것 확인. --- README.md | 1 + infra/app.env.example | 5 + src/app.module.ts | 2 + src/config/expo-push.config.spec.ts | 51 +++ src/config/expo-push.config.ts | 35 ++ src/features/conversation/index.ts | 1 + .../constants/seller-push.constants.ts | 5 + .../notification/notification.module.ts | 10 +- .../seller-push-delivery.repository.spec.ts | 225 ++++++++++ .../seller-push-delivery.repository.ts | 136 ++++++ .../seller-push-messages.helper.spec.ts | 55 +++ .../services/seller-push-messages.helper.ts | 51 +++ .../seller-push-outbox.consumer.spec.ts | 425 ++++++++++++++++++ .../services/seller-push-outbox.consumer.ts | 174 +++++++ .../seller-push-receipt.scheduler.spec.ts | 263 +++++++++++ .../services/seller-push-receipt.scheduler.ts | 118 +++++ src/features/order/index.ts | 1 + .../expo-push/expo-push.transport.spec.ts | 208 +++++++++ src/global/expo-push/expo-push.transport.ts | 136 ++++++ src/global/expo-push/index.ts | 11 + src/global/metrics/metrics.service.spec.ts | 1 + src/global/metrics/metrics.service.ts | 7 + 22 files changed, 1920 insertions(+), 1 deletion(-) create mode 100644 src/config/expo-push.config.spec.ts create mode 100644 src/config/expo-push.config.ts create mode 100644 src/features/notification/repositories/seller-push-delivery.repository.spec.ts create mode 100644 src/features/notification/repositories/seller-push-delivery.repository.ts create mode 100644 src/features/notification/services/seller-push-messages.helper.spec.ts create mode 100644 src/features/notification/services/seller-push-messages.helper.ts create mode 100644 src/features/notification/services/seller-push-outbox.consumer.spec.ts create mode 100644 src/features/notification/services/seller-push-outbox.consumer.ts create mode 100644 src/features/notification/services/seller-push-receipt.scheduler.spec.ts create mode 100644 src/features/notification/services/seller-push-receipt.scheduler.ts create mode 100644 src/global/expo-push/expo-push.transport.spec.ts create mode 100644 src/global/expo-push/expo-push.transport.ts create mode 100644 src/global/expo-push/index.ts diff --git a/README.md b/README.md index 9ee22137..7b9c315c 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ APP_ROLE=worker PORT=4001 yarn start:dev # (선택) 이벤트 소비까지 보 | **문서 · 메트릭** | `DOCS_ACCESS_TOKEN`(`/gql-docs`와 `/rest-docs` 접근 토큰, 운영 필수), `METRICS_ACCESS_TOKEN`(`/metrics` Bearer 토큰, 운영 필수) | | **경보 (선택)** | `DISCORD_ALERT_WEBHOOK_URL`(없으면 로그로만 남깁니다), `ALERT_DEDUPE_WINDOW_MS`(기본 5분), `BOOT_ALERT_STATE_DIR`(부팅 실패 경보의 억제 파일 위치, 기본 `~/.caquick/boot-alert`) | | **Outbox (선택)** | `OUTBOX_DISPATCH_ENABLED`(`false`로 끄는 용도만 있으며 켜는 것은 worker 역할이 결정), `OUTBOX_POLL_INTERVAL_MS`, `OUTBOX_BATCH_SIZE`, `OUTBOX_MAX_ATTEMPTS`, `OUTBOX_PARTITION_CONCURRENCY` | +| **푸시 (선택)** | `EXPO_PUSH_ENABLED`(`true`일 때만 worker가 판매자 앱 푸시를 보내고, 기본 `false`면 소비자가 전송 없이 ack합니다), `EXPO_PUSH_ACCESS_TOKEN`(Expo 액세스 토큰, 없으면 인증 헤더 없이 보냅니다), `EXPO_PUSH_TIMEOUT_MS`(기본 5000) | | **시드 (선택)** | `ADMIN_SEED_USERNAME`, `ADMIN_SEED_PASSWORD`, `SELLER_SEED_PASSWORD` | ### 자주 쓰는 스크립트 diff --git a/infra/app.env.example b/infra/app.env.example index eed45ea7..9ba651e3 100644 --- a/infra/app.env.example +++ b/infra/app.env.example @@ -46,3 +46,8 @@ DISCORD_ALERT_WEBHOOK_URL= ALERT_DEDUPE_WINDOW_MS= OUTBOX_MAX_ATTEMPTS= OUTBOX_PARTITION_CONCURRENCY= + +# ---- 앱: 판매자 푸시(Expo Push Service) ---- +EXPO_PUSH_ENABLED= # true일 때만 worker가 전송한다(기본 false — 소비자가 전송 없이 ack) +EXPO_PUSH_ACCESS_TOKEN= +EXPO_PUSH_TIMEOUT_MS= diff --git a/src/app.module.ts b/src/app.module.ts index 456e0133..ad74fb8a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import appConfig, { import authConfig from '@/config/auth.config'; import databaseConfig from '@/config/database.config'; import docsConfig from '@/config/docs.config'; +import expoPushConfig from '@/config/expo-push.config'; import kakaoLocalConfig from '@/config/kakao-local.config'; import metricsConfig from '@/config/metrics.config'; import oidcConfig from '@/config/oidc.config'; @@ -124,6 +125,7 @@ export class AppModule implements NestModule { authConfig, databaseConfig, docsConfig, + expoPushConfig, kakaoLocalConfig, metricsConfig, oidcConfig, diff --git a/src/config/expo-push.config.spec.ts b/src/config/expo-push.config.spec.ts new file mode 100644 index 00000000..b61ef2cd --- /dev/null +++ b/src/config/expo-push.config.spec.ts @@ -0,0 +1,51 @@ +import { readExpoPushConfig } from '@/config/expo-push.config'; + +describe('expoPushConfig', () => { + it('미설정이면 꺼짐·토큰 없음·타임아웃 5000', () => { + expect(readExpoPushConfig({})).toEqual({ + enabled: false, + accessToken: null, + requestTimeoutMs: 5_000, + }); + }); + + // 전송 스위치 — true/false(대소문자·공백 무시)만 인정하고 나머지는 꺼짐 + it.each([ + ['true', true], + ['TRUE', true], + [' true ', true], + ['false', false], + ['', false], + ['yes', false], + ['1', false], + ['on', false], + [undefined, false], + ])('EXPO_PUSH_ENABLED=%p → %s', (value, expected) => { + expect(readExpoPushConfig({ EXPO_PUSH_ENABLED: value }).enabled).toBe( + expected, + ); + }); + + it.each([ + [' tok ', 'tok'], + ['', null], + [' ', null], + [undefined, null], + ])('EXPO_PUSH_ACCESS_TOKEN=%p → %p', (value, expected) => { + expect( + readExpoPushConfig({ EXPO_PUSH_ACCESS_TOKEN: value }).accessToken, + ).toBe(expected); + }); + + it.each([ + ['250', 250], + ['0', 5_000], + ['-1', 5_000], + ['abc', 5_000], + ['', 5_000], + ])('EXPO_PUSH_TIMEOUT_MS=%p → %s', (value, expected) => { + expect( + readExpoPushConfig({ EXPO_PUSH_TIMEOUT_MS: value }).requestTimeoutMs, + ).toBe(expected); + }); +}); diff --git a/src/config/expo-push.config.ts b/src/config/expo-push.config.ts new file mode 100644 index 00000000..6aea978b --- /dev/null +++ b/src/config/expo-push.config.ts @@ -0,0 +1,35 @@ +import { registerAs } from '@nestjs/config'; + +import { + parseEnvBoolean, + parseEnvNumber, + parseEnvString, +} from '@/common/utils/env-parse'; + +export interface ExpoPushConfig { + /** 꺼져 있으면 소비자가 전송 없이 ack만 한다(이력도 남기지 않는다). 기본 false. */ + enabled: boolean; + /** Expo 액세스 토큰(선택). 있으면 Authorization: Bearer. */ + accessToken: string | null; + requestTimeoutMs: number; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +/** 선택 설정 — 부팅을 막는 조건이 없다. 비표준 값은 기본값으로 떨어진다. */ +export function readExpoPushConfig( + env: NodeJS.ProcessEnv = process.env, +): ExpoPushConfig { + return { + enabled: parseEnvBoolean(env.EXPO_PUSH_ENABLED, false), + accessToken: parseEnvString(env.EXPO_PUSH_ACCESS_TOKEN) ?? null, + requestTimeoutMs: parseEnvNumber( + env.EXPO_PUSH_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ), + }; +} + +export default registerAs('expoPush', (): ExpoPushConfig => + readExpoPushConfig(), +); diff --git a/src/features/conversation/index.ts b/src/features/conversation/index.ts index 33aa5ca8..fdaf7037 100644 --- a/src/features/conversation/index.ts +++ b/src/features/conversation/index.ts @@ -4,5 +4,6 @@ export { ConversationRepository } from '@/features/conversation/repositories/con // 구매자 메시지 전송 이벤트 계약(outbox). 판매자 푸시 소비자(notification)가 payload 스냅샷만 읽는다. export { CONVERSATION_BUYER_MESSAGE_SENT, + type ConversationBuyerMessageSentPayload, parseConversationBuyerMessageSentPayload, } from '@/features/conversation/events/conversation-buyer-message-sent.event'; diff --git a/src/features/notification/constants/seller-push.constants.ts b/src/features/notification/constants/seller-push.constants.ts index b7f3a805..5a5995bb 100644 --- a/src/features/notification/constants/seller-push.constants.ts +++ b/src/features/notification/constants/seller-push.constants.ts @@ -16,3 +16,8 @@ export const PUSH_DEVICE_DISABLED_REASON = { } as const; export type PushDeviceDisabledReason = (typeof PUSH_DEVICE_DISABLED_REASON)[keyof typeof PUSH_DEVICE_DISABLED_REASON]; + +/** Expo ticket·receipt 오류 코드. 토큰이 죽은 디바이스는 즉시 비활성한다. */ +export const EXPO_ERROR_DEVICE_NOT_REGISTERED = 'DeviceNotRegistered'; +/** Expo가 오류 코드를 안 준 오류 */ +export const EXPO_ERROR_UNKNOWN = 'UNKNOWN'; diff --git a/src/features/notification/notification.module.ts b/src/features/notification/notification.module.ts index e47ede86..f4964fe9 100644 --- a/src/features/notification/notification.module.ts +++ b/src/features/notification/notification.module.ts @@ -4,6 +4,7 @@ import { AuditLogModule } from '@/features/audit-log'; import { AuthModule } from '@/features/auth'; import { NotificationAdminRepository } from '@/features/notification/repositories/notification-admin.repository'; import { NotificationRepository } from '@/features/notification/repositories/notification.repository'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; import { AdminNotificationMutationResolver } from '@/features/notification/resolvers/notification-admin-mutation.resolver'; import { AdminNotificationQueryResolver } from '@/features/notification/resolvers/notification-admin-query.resolver'; @@ -14,10 +15,13 @@ import { AdminNotificationService } from '@/features/notification/services/notif import { UserNotificationService } from '@/features/notification/services/notification-my.service'; import { NotificationOutboxConsumer } from '@/features/notification/services/notification-outbox.consumer'; import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service'; +import { SellerPushOutboxConsumer } from '@/features/notification/services/seller-push-outbox.consumer'; +import { SellerPushReceiptScheduler } from '@/features/notification/services/seller-push-receipt.scheduler'; import { OutboxModule } from '@/features/outbox'; import { StoreModule } from '@/features/store'; +import { EXPO_PUSH_TRANSPORT, expoPushTransport } from '@/global/expo-push'; -/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점)·판매자 푸시 디바이스. */ +/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점)·판매자 푸시 디바이스와 전송. */ @Module({ // StoreModule은 판매자 컨텍스트(SellerBaseService → StoreSellerRepository)용 imports: [AuthModule, AuditLogModule, OutboxModule, StoreModule], @@ -34,6 +38,10 @@ import { StoreModule } from '@/features/store'; SellerPushDeviceRepository, SellerPushDeviceService, SellerPushDeviceMutationResolver, + SellerPushDeliveryRepository, + SellerPushOutboxConsumer, + SellerPushReceiptScheduler, + { provide: EXPO_PUSH_TRANSPORT, useValue: expoPushTransport }, ], // 미읽 수는 뷰어 카운트(user → 05c mypage)가 배럴로 읽는다 exports: [NotificationRepository], diff --git a/src/features/notification/repositories/seller-push-delivery.repository.spec.ts b/src/features/notification/repositories/seller-push-delivery.repository.spec.ts new file mode 100644 index 00000000..33117ff9 --- /dev/null +++ b/src/features/notification/repositories/seller-push-delivery.repository.spec.ts @@ -0,0 +1,225 @@ +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createSellerPushDelivery, + createSellerPushDevice, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const EVENT_ID = '11111111-1111-4111-8111-111111111111'; +const AT = new Date('2026-10-05T12:00:00.000Z'); + +describe('SellerPushDeliveryRepository (real DB)', () => { + let repo: SellerPushDeliveryRepository; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SellerPushDeliveryRepository], + }); + repo = module.get(SellerPushDeliveryRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('claim', () => { + it('디바이스마다 PENDING 행을 만들고 그 행들을 디바이스 id 순으로 돌려준다', async () => { + const a = await createSellerPushDevice(prisma); + const b = await createSellerPushDevice(prisma); + + const rows = await repo.claim(EVENT_ID, [b.id, a.id]); + + expect(rows.map((r) => r.push_device_id)).toEqual([a.id, b.id]); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + expect( + await prisma.sellerPushDelivery.findMany({ select: { status: true } }), + ).toEqual([{ status: 'PENDING' }, { status: 'PENDING' }]); + }); + + it('재전달: ticket이 기록된 디바이스는 빼고 PENDING인 디바이스만 다시 돌려주며 행을 더 만들지 않는다', async () => { + const done = await createSellerPushDevice(prisma); + const pending = await createSellerPushDevice(prisma); + await createSellerPushDelivery(prisma, { + source_event_id: EVENT_ID, + push_device_id: done.id, + status: 'TICKET_OK', + ticket_id: 't-1', + sent_at: AT, + }); + await createSellerPushDelivery(prisma, { + source_event_id: EVENT_ID, + push_device_id: pending.id, + }); + + const rows = await repo.claim(EVENT_ID, [done.id, pending.id]); + + expect(rows.map((r) => r.push_device_id)).toEqual([pending.id]); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + }); + + it('다른 이벤트의 행은 선점에 영향을 주지 않는다', async () => { + const device = await createSellerPushDevice(prisma); + await createSellerPushDelivery(prisma, { + source_event_id: '22222222-2222-4222-8222-222222222222', + push_device_id: device.id, + status: 'TICKET_OK', + }); + + const rows = await repo.claim(EVENT_ID, [device.id]); + + expect(rows).toHaveLength(1); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + }); + + it('반증: 없는 디바이스 id는 FK 오류로 던진다(skipDuplicates였다면 조용히 0건)', async () => { + await expect(repo.claim(EVENT_ID, [424242n])).rejects.toThrow(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + + it('디바이스가 없으면 빈 배열', async () => { + expect(await repo.claim(EVENT_ID, [])).toEqual([]); + }); + }); + + describe('markTickets', () => { + it('ticket 결과를 행마다 기록한다', async () => { + const ok = await createSellerPushDelivery(prisma); + const failed = await createSellerPushDelivery(prisma); + + await repo.markTickets([ + { id: ok.id, status: 'TICKET_OK', ticketId: 't-ok', sentAt: AT }, + { + id: failed.id, + status: 'TICKET_ERROR', + errorCode: 'DeviceNotRegistered', + sentAt: AT, + }, + ]); + + expect( + await prisma.sellerPushDelivery.findUnique({ where: { id: ok.id } }), + ).toMatchObject({ + status: 'TICKET_OK', + ticket_id: 't-ok', + error_code: null, + sent_at: AT, + }); + expect( + await prisma.sellerPushDelivery.findUnique({ + where: { id: failed.id }, + }), + ).toMatchObject({ + status: 'TICKET_ERROR', + ticket_id: null, + error_code: 'DeviceNotRegistered', + sent_at: AT, + }); + }); + }); + + describe('listForReceipt', () => { + it('기준 시각 이전에 보낸 미확인 TICKET_OK 행만 오래된 순으로, limit까지', async () => { + const old = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-old', + sent_at: new Date('2026-10-05T11:00:00.000Z'), + }); + const older = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-older', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-recent', + sent_at: AT, + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-checked', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + receipt_checked_at: AT, + }); + await createSellerPushDelivery(prisma, { + status: 'TICKET_ERROR', + error_code: 'MessageTooBig', + sent_at: new Date('2026-10-05T10:00:00.000Z'), + }); + + const rows = await repo.listForReceipt({ sentBefore: AT, limit: 10 }); + expect(rows.map((r) => r.id)).toEqual([older.id, old.id]); + expect(rows[0]).toEqual({ + id: older.id, + push_device_id: older.push_device_id, + ticket_id: 't-older', + sent_at: older.sent_at, + }); + + const limited = await repo.listForReceipt({ sentBefore: AT, limit: 1 }); + expect(limited.map((r) => r.id)).toEqual([older.id]); + }); + }); + + describe('markReceipts', () => { + it('영수증 결과와 확인 시각을 기록하고, 오류가 아니면 error_code를 건드리지 않는다', async () => { + const ok = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-1', + }); + const failed = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-2', + }); + const unknown = await createSellerPushDelivery(prisma, { + status: 'TICKET_OK', + ticket_id: 't-3', + }); + + await repo.markReceipts( + [ + { id: ok.id, status: 'RECEIPT_OK' }, + { + id: failed.id, + status: 'RECEIPT_ERROR', + errorCode: 'MessageTooBig', + }, + { id: unknown.id, status: 'RECEIPT_UNKNOWN' }, + ], + AT, + ); + + const byId = async (id: bigint) => + prisma.sellerPushDelivery.findUnique({ where: { id } }); + expect(await byId(ok.id)).toMatchObject({ + status: 'RECEIPT_OK', + error_code: null, + receipt_checked_at: AT, + }); + expect(await byId(failed.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'MessageTooBig', + receipt_checked_at: AT, + }); + expect(await byId(unknown.id)).toMatchObject({ + status: 'RECEIPT_UNKNOWN', + error_code: null, + receipt_checked_at: AT, + }); + }); + + it('빈 결과는 아무것도 하지 않는다', async () => { + await expect(repo.markReceipts([], AT)).resolves.toBeUndefined(); + await expect(repo.markTickets([])).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/features/notification/repositories/seller-push-delivery.repository.ts b/src/features/notification/repositories/seller-push-delivery.repository.ts new file mode 100644 index 00000000..23d8c479 --- /dev/null +++ b/src/features/notification/repositories/seller-push-delivery.repository.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; + +import type { Prisma } from '@/generated/prisma/client'; +import { PrismaService } from '@/prisma'; + +const pendingSelect = { + id: true, + push_device_id: true, +} satisfies Prisma.SellerPushDeliverySelect; +export type PendingDeliveryRow = Prisma.SellerPushDeliveryGetPayload<{ + select: typeof pendingSelect; +}>; + +const receiptSelect = { + id: true, + push_device_id: true, + ticket_id: true, + sent_at: true, +} satisfies Prisma.SellerPushDeliverySelect; +export type ReceiptCandidateRow = Prisma.SellerPushDeliveryGetPayload<{ + select: typeof receiptSelect; +}> & { ticket_id: string; sent_at: Date }; + +export type TicketResult = + | { id: bigint; status: 'TICKET_OK'; ticketId: string; sentAt: Date } + | { id: bigint; status: 'TICKET_ERROR'; errorCode: string; sentAt: Date }; + +export type ReceiptResult = + | { id: bigint; status: 'RECEIPT_OK' | 'RECEIPT_UNKNOWN' } + | { id: bigint; status: 'RECEIPT_ERROR'; errorCode: string }; + +/** outbox 이벤트 1건 × 디바이스 1개 = 1행. at-least-once 재전달의 dedupe 키이자 ticket → receipt 추적 행. */ +@Injectable() +export class SellerPushDeliveryRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 재전달 흡수: 같은 이벤트로 이미 들어간 디바이스는 빼고 넣는다(unique (source_event_id, push_device_id)가 최종 방어). + * skipDuplicates(INSERT IGNORE)는 FK 위반 같은 다른 오류까지 삼켜 조용히 0건이 되므로 쓰지 않는다. + * 반환은 이 디바이스들 중 아직 전송 전(PENDING)인 행 — 전송은 했는데 ticket 기록 전에 죽은 행은 다시 보내진다(허용 범위). + */ + async claim( + sourceEventId: string, + deviceIds: bigint[], + ): Promise { + if (deviceIds.length === 0) return []; + const claimed = new Set( + ( + await this.prisma.sellerPushDelivery.findMany({ + where: { + source_event_id: sourceEventId, + push_device_id: { in: deviceIds }, + }, + select: { push_device_id: true }, + }) + ).map((row) => row.push_device_id.toString()), + ); + const fresh = deviceIds.filter((id) => !claimed.has(id.toString())); + if (fresh.length > 0) { + await this.prisma.sellerPushDelivery.createMany({ + data: fresh.map((push_device_id) => ({ + source_event_id: sourceEventId, + push_device_id, + })), + }); + } + return this.prisma.sellerPushDelivery.findMany({ + where: { + source_event_id: sourceEventId, + push_device_id: { in: deviceIds }, + status: 'PENDING', + }, + select: pendingSelect, + orderBy: { push_device_id: 'asc' }, + }); + } + + async markTickets(results: TicketResult[]): Promise { + if (results.length === 0) return; + await this.prisma.$transaction( + results.map((result) => + this.prisma.sellerPushDelivery.update({ + where: { id: result.id }, + data: + result.status === 'TICKET_OK' + ? { + status: 'TICKET_OK', + ticket_id: result.ticketId, + sent_at: result.sentAt, + } + : { + status: 'TICKET_ERROR', + error_code: result.errorCode, + sent_at: result.sentAt, + }, + }), + ), + ); + } + + /** 영수증을 아직 안 본 TICKET_OK 행 — 오래된 것부터. */ + async listForReceipt(args: { + sentBefore: Date; + limit: number; + }): Promise { + const rows = await this.prisma.sellerPushDelivery.findMany({ + where: { + status: 'TICKET_OK', + sent_at: { lt: args.sentBefore }, + receipt_checked_at: null, + ticket_id: { not: null }, + }, + select: receiptSelect, + orderBy: { sent_at: 'asc' }, + take: args.limit, + }); + return rows as ReceiptCandidateRow[]; + } + + async markReceipts(results: ReceiptResult[], checkedAt: Date): Promise { + if (results.length === 0) return; + await this.prisma.$transaction( + results.map((result) => + this.prisma.sellerPushDelivery.update({ + where: { id: result.id }, + data: { + status: result.status, + error_code: + result.status === 'RECEIPT_ERROR' ? result.errorCode : undefined, + receipt_checked_at: checkedAt, + }, + }), + ), + ); + } +} diff --git a/src/features/notification/services/seller-push-messages.helper.spec.ts b/src/features/notification/services/seller-push-messages.helper.spec.ts new file mode 100644 index 00000000..33370ddf --- /dev/null +++ b/src/features/notification/services/seller-push-messages.helper.spec.ts @@ -0,0 +1,55 @@ +import { + buildBuyerMessagePush, + buildOrderSubmittedPush, + formatKstPickupAt, +} from '@/features/notification/services/seller-push-messages.helper'; + +describe('seller-push-messages.helper', () => { + // UTC → KST(+9), 월·일은 0 채움 없이, 시·분은 두 자리 + it.each([ + ['2026-10-05T03:05:00.000Z', '10/5 12:05'], + ['2026-10-05T15:30:00.000Z', '10/6 00:30'], + ['2026-12-31T16:00:00.000Z', '1/1 01:00'], + ['2026-03-09T00:00:00.000Z', '3/9 09:00'], + ])('formatKstPickupAt(%s) → %s', (iso, expected) => { + expect(formatKstPickupAt(new Date(iso))).toBe(expected); + }); + + it('주문 접수: 상품명·수량·픽업 시각(KST) 본문, 딥링크 data는 kind·orderId', () => { + expect( + buildOrderSubmittedPush({ + orderId: '42', + orderNumber: 'ORD-1', + buyerAccountId: '7', + storeId: '3', + storeName: '케이크샵', + productId: '9', + productName: '레터링 케이크', + quantity: 2, + pickupAt: '2026-10-05T03:05:00.000Z', + totalPrice: 50000, + }), + ).toEqual({ + title: '새 주문', + body: '레터링 케이크 2개 · 픽업 10/5 12:05', + data: { kind: 'ORDER_SUBMITTED', orderId: '42' }, + }); + }); + + it('구매자 문의: 본문은 preview 그대로, data는 kind·conversationId', () => { + expect( + buildBuyerMessagePush({ + conversationId: '11', + storeId: '3', + buyerAccountId: '7', + messageId: '99', + preview: '픽업 시간 바꿀 수 있나요?', + messageCreatedAt: '2026-10-05T03:05:00.000Z', + }), + ).toEqual({ + title: '새 문의', + body: '픽업 시간 바꿀 수 있나요?', + data: { kind: 'BUYER_MESSAGE', conversationId: '11' }, + }); + }); +}); diff --git a/src/features/notification/services/seller-push-messages.helper.ts b/src/features/notification/services/seller-push-messages.helper.ts new file mode 100644 index 00000000..89e8099c --- /dev/null +++ b/src/features/notification/services/seller-push-messages.helper.ts @@ -0,0 +1,51 @@ +import { + formatMinutesOfDay, + kstMinutesOfDay, + toKstYmd, +} from '@/common/utils/kst-time'; +import type { ConversationBuyerMessageSentPayload } from '@/features/conversation'; +import type { OrderSubmittedPayload } from '@/features/order'; + +/** 앱 딥링크 키. */ +const SELLER_PUSH_KIND = { + ORDER_SUBMITTED: 'ORDER_SUBMITTED', + BUYER_MESSAGE: 'BUYER_MESSAGE', +} as const; + +/** Android 알림 채널 — 앱이 같은 id로 만든다. */ +export const SELLER_PUSH_CHANNEL_ID = 'default'; + +export interface SellerPushContent { + title: string; + body: string; + data: Record; +} + +/** `M/d HH:mm`(KST). */ +export function formatKstPickupAt(date: Date): string { + const { month, day } = toKstYmd(date); + return `${month}/${day} ${formatMinutesOfDay(kstMinutesOfDay(date))}`; +} + +export function buildOrderSubmittedPush( + p: OrderSubmittedPayload, +): SellerPushContent { + return { + title: '새 주문', + body: `${p.productName} ${p.quantity}개 · 픽업 ${formatKstPickupAt(new Date(p.pickupAt))}`, + data: { kind: SELLER_PUSH_KIND.ORDER_SUBMITTED, orderId: p.orderId }, + }; +} + +export function buildBuyerMessagePush( + p: ConversationBuyerMessageSentPayload, +): SellerPushContent { + return { + title: '새 문의', + body: p.preview, + data: { + kind: SELLER_PUSH_KIND.BUYER_MESSAGE, + conversationId: p.conversationId, + }, + }; +} diff --git a/src/features/notification/services/seller-push-outbox.consumer.spec.ts b/src/features/notification/services/seller-push-outbox.consumer.spec.ts new file mode 100644 index 00000000..9de29aa1 --- /dev/null +++ b/src/features/notification/services/seller-push-outbox.consumer.spec.ts @@ -0,0 +1,425 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushOutboxConsumer } from '@/features/notification/services/seller-push-outbox.consumer'; +import type { OutboxEvent } from '@/features/outbox'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + type ExpoPushTicket, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { MetricsService } from '@/global/metrics'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createSellerPushDevice, setupSellerWithStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const NOW = new Date('2026-10-05T12:00:00.000Z'); +const EVENT_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_EVENT_ID = '22222222-2222-4222-8222-222222222222'; + +function event( + eventType: string, + payload: OutboxEvent['payload'], + eventId = EVENT_ID, +): OutboxEvent { + return { + id: 1n, + eventId, + aggregateType: 'test', + aggregateId: '1', + eventType, + payload, + occurredAt: NOW, + actorAccountId: null, + clientIp: null, + userAgent: null, + attempts: 0, + }; +} + +function orderSubmitted(storeId: bigint, eventId = EVENT_ID): OutboxEvent { + return event( + 'order.submitted', + { + orderId: '42', + orderNumber: 'ORD-1', + buyerAccountId: '7', + storeId: storeId.toString(), + storeName: '케이크샵', + productId: '9', + productName: '레터링 케이크', + quantity: 2, + pickupAt: '2026-10-05T03:05:00.000Z', + totalPrice: 50000, + }, + eventId, + ); +} + +function okTickets(messages: ExpoPushMessage[]): ExpoPushTicket[] { + return messages.map((m) => ({ status: 'ok', id: `ticket:${m.to}` })); +} + +// 판매자 푸시 전송 — 매장 디바이스 fan-out, 재전달 멱등(ticket 기록 행 제외), 배치 100, ticket 오류·인증 실패 처리. +describe('SellerPushOutboxConsumer (real DB)', () => { + let consumer: SellerPushOutboxConsumer; + let metrics: MetricsService; + let prisma: PrismaClient; + let cfg: ExpoPushConfig; + const send = jest.fn< + Promise, + Parameters + >(); + const transport: ExpoPushTransport = { + send, + getReceipts: jest.fn(), + }; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushOutboxConsumer, + SellerPushDeviceRepository, + SellerPushDeliveryRepository, + MetricsService, + { provide: ClockService, useValue: { now: () => NOW } }, + { provide: ConfigService, useValue: { getOrThrow: () => cfg } }, + { provide: AlertService, useValue: alerts }, + { provide: EXPO_PUSH_TRANSPORT, useValue: transport }, + ], + }); + consumer = module.get(SellerPushOutboxConsumer); + metrics = module.get(MetricsService); + prisma = p; + }); + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + cfg = { enabled: true, accessToken: 'tok', requestTimeoutMs: 1_000 }; + send.mockReset(); + send.mockImplementation((messages) => Promise.resolve(okTickets(messages))); + alerts.notify.mockClear(); + metrics.expoPushSends.reset(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + async function storeWithDevices(count: number) { + const seller = await setupSellerWithStore(prisma); + const devices = []; + for (let i = 0; i < count; i++) { + devices.push( + await createSellerPushDevice(prisma, { + account_id: seller.account.id, + store_id: seller.store.id, + }), + ); + } + return { storeId: seller.store.id, devices }; + } + + async function sendsCounter(): Promise> { + const { values } = await metrics.expoPushSends.get(); + return Object.fromEntries( + values.map((v) => [v.labels.result as string, v.value]), + ); + } + + const deliveries = () => + prisma.sellerPushDelivery.findMany({ orderBy: { id: 'asc' } }); + + describe('order.submitted', () => { + it('매장의 활성 디바이스마다 메시지를 한 요청으로 보내고 ticket을 행에 기록한다', async () => { + const { storeId, devices } = await storeWithDevices(2); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(1); + const [messages, options] = send.mock.calls[0]; + expect(options).toEqual({ accessToken: 'tok', timeoutMs: 1_000 }); + expect(messages).toEqual( + devices.map((d) => ({ + to: d.expo_push_token, + title: '새 주문', + body: '레터링 케이크 2개 · 픽업 10/5 12:05', + data: { kind: 'ORDER_SUBMITTED', orderId: '42' }, + channelId: 'default', + })), + ); + const rows = await deliveries(); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.push_device_id)).toEqual( + devices.map((d) => d.id), + ); + expect(rows[0]).toMatchObject({ + source_event_id: EVENT_ID, + status: 'TICKET_OK', + ticket_id: `ticket:${devices[0].expo_push_token}`, + error_code: null, + sent_at: NOW, + receipt_checked_at: null, + }); + expect(await sendsCounter()).toEqual({ TICKET_OK: 2 }); + }); + + it('해제된 디바이스와 다른 매장의 디바이스는 대상이 아니다', async () => { + const { storeId, devices } = await storeWithDevices(1); + await createSellerPushDevice(prisma, { + store_id: storeId, + account_id: devices[0].account_id, + disabled_at: NOW, + disabled_reason: 'UNREGISTERED', + }); + await createSellerPushDevice(prisma); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send.mock.calls[0][0].map((m) => m.to)).toEqual([ + devices[0].expo_push_token, + ]); + expect(await prisma.sellerPushDelivery.count()).toBe(1); + }); + + it('디바이스가 없으면 보내지 않고 행도 남기지 않는다', async () => { + const { storeId } = await storeWithDevices(0); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + + it('같은 이벤트 재전달은 다시 보내지 않는다 — 반증: 다른 이벤트는 보낸다', async () => { + const { storeId } = await storeWithDevices(2); + await consumer.handle(orderSubmitted(storeId)); + expect(send).toHaveBeenCalledTimes(1); + + await consumer.handle(orderSubmitted(storeId)); + expect(send).toHaveBeenCalledTimes(1); + expect(await prisma.sellerPushDelivery.count()).toBe(2); + + await consumer.handle(orderSubmitted(storeId, OTHER_EVENT_ID)); + expect(send).toHaveBeenCalledTimes(2); + expect(await prisma.sellerPushDelivery.count()).toBe(4); + }); + + it('디바이스 150개는 100 + 50 두 요청으로 나눈다', async () => { + const { storeId } = await storeWithDevices(150); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send.mock.calls.map(([m]) => m.length)).toEqual([100, 50]); + expect( + await prisma.sellerPushDelivery.count({ + where: { status: 'TICKET_OK' }, + }), + ).toBe(150); + expect(await sendsCounter()).toEqual({ TICKET_OK: 150 }); + }); + + it('ticket DeviceNotRegistered는 행에 오류 코드를 남기고 그 디바이스만 비활성한다', async () => { + const { storeId, devices } = await storeWithDevices(2); + send.mockResolvedValue([ + { + status: 'error', + message: 'not registered', + details: { error: 'DeviceNotRegistered' }, + }, + { status: 'ok', id: 't-2' }, + ]); + + await consumer.handle(orderSubmitted(storeId)); + + const rows = await deliveries(); + expect(rows[0]).toMatchObject({ + status: 'TICKET_ERROR', + error_code: 'DeviceNotRegistered', + ticket_id: null, + sent_at: NOW, + }); + expect(rows[1]).toMatchObject({ status: 'TICKET_OK', ticket_id: 't-2' }); + const [gone, alive] = await prisma.sellerPushDevice.findMany({ + where: { id: { in: devices.map((d) => d.id) } }, + orderBy: { id: 'asc' }, + }); + expect(gone).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(alive.disabled_at).toBeNull(); + expect(await sendsCounter()).toEqual({ TICKET_ERROR: 1, TICKET_OK: 1 }); + expect(Logger.prototype.warn).toHaveBeenCalled(); + }); + + it('오류 코드가 없는 ticket 오류는 UNKNOWN으로 남기고 디바이스는 살려 둔다', async () => { + const { storeId, devices } = await storeWithDevices(1); + send.mockResolvedValue([{ status: 'error', message: 'too big' }]); + + await consumer.handle(orderSubmitted(storeId)); + + expect((await deliveries())[0]).toMatchObject({ + status: 'TICKET_ERROR', + error_code: 'UNKNOWN', + }); + expect( + ( + await prisma.sellerPushDevice.findUniqueOrThrow({ + where: { id: devices[0].id }, + }) + ).disabled_at, + ).toBeNull(); + }); + + it('전송이 던지면 그대로 던지고 행은 PENDING으로 남아 재전달 때 다시 보낸다', async () => { + const { storeId, devices } = await storeWithDevices(2); + send.mockRejectedValueOnce(new ExpoPushHttpError(503, 'Expo 푸시 전송')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'HTTP 503', + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'PENDING', + 'PENDING', + ]); + expect(alerts.notify).not.toHaveBeenCalled(); + expect(await sendsCounter()).toEqual({}); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[1][0].map((m) => m.to)).toEqual( + devices.map((d) => d.expo_push_token), + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'TICKET_OK', + 'TICKET_OK', + ]); + }); + + it('두 번째 배치만 실패하면 첫 배치는 기록되고 재전달은 남은 50개만 보낸다', async () => { + const { storeId } = await storeWithDevices(150); + send + .mockImplementationOnce((messages) => + Promise.resolve(okTickets(messages)), + ) + .mockRejectedValueOnce(new Error('fetch failed')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'fetch failed', + ); + expect( + await prisma.sellerPushDelivery.groupBy({ + by: ['status'], + _count: true, + orderBy: { status: 'asc' }, + }), + ).toEqual([ + { status: 'PENDING', _count: 50 }, + { status: 'TICKET_OK', _count: 100 }, + ]); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).toHaveBeenCalledTimes(3); + expect(send.mock.calls[2][0]).toHaveLength(50); + expect( + await prisma.sellerPushDelivery.count({ where: { status: 'PENDING' } }), + ).toBe(0); + }); + + it('401·403은 인증 실패 경보를 낸 뒤 던진다', async () => { + const { storeId } = await storeWithDevices(2); + send.mockRejectedValue(new ExpoPushAuthError(401, 'Expo 푸시 전송')); + + await expect( + consumer.handle(orderSubmitted(storeId)), + ).rejects.toBeInstanceOf(ExpoPushAuthError); + + expect(alerts.notify).toHaveBeenCalledTimes(1); + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: expect.stringContaining('HTTP 401'), + }), + ); + expect((await deliveries()).map((r) => r.status)).toEqual([ + 'PENDING', + 'PENDING', + ]); + expect(await sendsCounter()).toEqual({ AUTH_ERROR: 2 }); + }); + + it('EXPO_PUSH_ENABLED=false면 디바이스가 있어도 보내지 않고 이력도 남기지 않는다', async () => { + cfg = { ...cfg, enabled: false }; + const { storeId } = await storeWithDevices(1); + + await consumer.handle(orderSubmitted(storeId)); + + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + expect(Logger.prototype.debug).toHaveBeenCalled(); + }); + + it('반증: payload 형식이 어긋나면 던진다(전송·행 없음)', async () => { + const { storeId } = await storeWithDevices(1); + const broken = orderSubmitted(storeId); + broken.payload = { ...(broken.payload as object), quantity: '2' }; + + await expect(consumer.handle(broken)).rejects.toThrow( + 'order.submitted payload 형식 오류', + ); + expect(send).not.toHaveBeenCalled(); + expect(await prisma.sellerPushDelivery.count()).toBe(0); + }); + }); + + it('conversation.buyer_message_sent는 preview를 본문으로, 딥링크는 대화 id', async () => { + const { storeId, devices } = await storeWithDevices(1); + + await consumer.handle( + event('conversation.buyer_message_sent', { + conversationId: '11', + storeId: storeId.toString(), + buyerAccountId: '7', + messageId: '99', + preview: '픽업 시간 바꿀 수 있나요?', + messageCreatedAt: '2026-10-05T03:05:00.000Z', + }), + ); + + expect(send.mock.calls[0][0]).toEqual([ + { + to: devices[0].expo_push_token, + title: '새 문의', + body: '픽업 시간 바꿀 수 있나요?', + data: { kind: 'BUYER_MESSAGE', conversationId: '11' }, + channelId: 'default', + }, + ]); + expect((await deliveries())[0].status).toBe('TICKET_OK'); + }); + + it('반증: 구독하지 않은 event_type은 던진다', async () => { + await expect( + consumer.handle(event('order.status_changed', {})), + ).rejects.toThrow('구독하지 않은 outbox 이벤트: order.status_changed'); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/notification/services/seller-push-outbox.consumer.ts b/src/features/notification/services/seller-push-outbox.consumer.ts new file mode 100644 index 00000000..8763493b --- /dev/null +++ b/src/features/notification/services/seller-push-outbox.consumer.ts @@ -0,0 +1,174 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import { parseId } from '@/common/utils/id-parser'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { + CONVERSATION_BUYER_MESSAGE_SENT, + parseConversationBuyerMessageSentPayload, +} from '@/features/conversation'; +import { + EXPO_ERROR_DEVICE_NOT_REGISTERED, + EXPO_ERROR_UNKNOWN, + PUSH_DEVICE_DISABLED_REASON, +} from '@/features/notification/constants/seller-push.constants'; +import { + SellerPushDeliveryRepository, + type TicketResult, +} from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { + buildBuyerMessagePush, + buildOrderSubmittedPush, + SELLER_PUSH_CHANNEL_ID, + type SellerPushContent, +} from '@/features/notification/services/seller-push-messages.helper'; +import { ORDER_SUBMITTED, parseOrderSubmittedPayload } from '@/features/order'; +import { + type OutboxConsumer, + type OutboxEvent, + SubscribeOutbox, +} from '@/features/outbox'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_SEND_LIMIT, + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushTicket, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { MetricsService } from '@/global/metrics'; + +/** + * 판매자 앱 푸시 전송(outbox 소비자, worker). 이벤트 payload 스냅샷으로 메시지를 만들고 매장의 활성 디바이스에 보낸다. + * 재전달은 (source_event_id, push_device_id) 행으로 흡수한다 — ticket이 기록된 디바이스에는 다시 보내지 않는다. + * 전송 실패(네트워크·5xx·429)는 던져 호스트의 retry/DLQ에 맡기고, 인증 실패는 경보를 낸 뒤 던진다. + */ +@Injectable() +@SubscribeOutbox(ORDER_SUBMITTED, CONVERSATION_BUYER_MESSAGE_SENT) +export class SellerPushOutboxConsumer implements OutboxConsumer { + private readonly logger = new Logger(SellerPushOutboxConsumer.name); + + constructor( + private readonly config: ConfigService, + private readonly devices: SellerPushDeviceRepository, + private readonly deliveries: SellerPushDeliveryRepository, + private readonly clock: ClockService, + private readonly alerts: AlertService, + private readonly metrics: MetricsService, + @Inject(EXPO_PUSH_TRANSPORT) private readonly transport: ExpoPushTransport, + ) {} + + async handle(event: OutboxEvent): Promise { + const cfg = this.config.getOrThrow('expoPush'); + if (!cfg.enabled) { + // 꺼진 동안의 이벤트는 복구하지 않는다 — retry 큐에 쌓이면 상한 뒤 DLQ 경보만 는다 + this.logger.debug( + `EXPO_PUSH_ENABLED=false — ${event.eventType}#${event.eventId} 전송 생략`, + ); + return; + } + const { storeId, content } = this.contentOf(event); + const devices = await this.devices.listActiveByStore(storeId); + if (devices.length === 0) return; + + const pending = await this.deliveries.claim( + event.eventId, + devices.map((device) => device.id), + ); + const tokenByDevice = new Map( + devices.map((device) => [device.id.toString(), device.expo_push_token]), + ); + const options = { + accessToken: cfg.accessToken, + timeoutMs: cfg.requestTimeoutMs, + }; + for (let i = 0; i < pending.length; i += EXPO_PUSH_SEND_LIMIT) { + const chunk = pending.slice(i, i + EXPO_PUSH_SEND_LIMIT); + const messages = chunk.map((row) => ({ + to: tokenByDevice.get(row.push_device_id.toString()) as string, + ...content, + channelId: SELLER_PUSH_CHANNEL_ID, + })); + let tickets: ExpoPushTicket[]; + try { + tickets = await this.transport.send(messages, options); + } catch (error) { + if (error instanceof ExpoPushAuthError) { + this.metrics.expoPushSends.inc( + { result: 'AUTH_ERROR' }, + messages.length, + ); + await this.alerts.notify({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: `${error.message} — EXPO_PUSH_ACCESS_TOKEN 확인. 이벤트 ${event.eventId}`, + }); + } + throw error; + } + const sentAt = this.clock.now(); + const results: TicketResult[] = []; + const notRegistered: bigint[] = []; + const codes: string[] = []; + chunk.forEach((row, index) => { + const ticket = tickets[index]; + if (ticket.status === 'ok') { + results.push({ + id: row.id, + status: 'TICKET_OK', + ticketId: ticket.id, + sentAt, + }); + return; + } + const errorCode = ticket.details?.error ?? EXPO_ERROR_UNKNOWN; + results.push({ id: row.id, status: 'TICKET_ERROR', errorCode, sentAt }); + codes.push(errorCode); + if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) + notRegistered.push(row.push_device_id); + }); + await this.deliveries.markTickets(results); + await this.devices.disableByIds( + notRegistered, + PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, + sentAt, + ); + for (const result of results) { + this.metrics.expoPushSends.inc({ result: result.status }); + } + if (codes.length > 0) { + this.logger.warn(`Expo ticket 오류 ${codes.length}건`, { + eventId: event.eventId, + codes, + }); + } + } + } + + private contentOf(event: OutboxEvent): { + storeId: bigint; + content: SellerPushContent; + } { + switch (event.eventType) { + case ORDER_SUBMITTED: { + const p = parseOrderSubmittedPayload(event.payload); + return { + storeId: parseId(p.storeId), + content: buildOrderSubmittedPush(p), + }; + } + case CONVERSATION_BUYER_MESSAGE_SENT: { + const p = parseConversationBuyerMessageSentPayload(event.payload); + return { + storeId: parseId(p.storeId), + content: buildBuyerMessagePush(p), + }; + } + default: + throw new Error(`구독하지 않은 outbox 이벤트: ${event.eventType}`); + } + } +} diff --git a/src/features/notification/services/seller-push-receipt.scheduler.spec.ts b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts new file mode 100644 index 00000000..42b0084e --- /dev/null +++ b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts @@ -0,0 +1,263 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ClockService } from '@/common/providers/clock.service'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { SellerPushReceiptScheduler } from '@/features/notification/services/seller-push-receipt.scheduler'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushReceipt, + type ExpoPushTransport, +} from '@/global/expo-push'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createSellerPushDelivery, + createSellerPushDevice, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +const NOW = new Date('2026-10-05T12:00:00.000Z'); +const MINUTE = 60 * 1000; +const minutesAgo = (minutes: number) => + new Date(NOW.getTime() - minutes * MINUTE); + +// 영수증 조회 — 15분 지난 TICKET_OK만, 오류면 디바이스 비활성, 24h 넘게 영수증이 없으면 UNKNOWN으로 닫는다. 실패는 경보만. +describe('SellerPushReceiptScheduler (real DB)', () => { + let scheduler: SellerPushReceiptScheduler; + let prisma: PrismaClient; + let cfg: ExpoPushConfig; + const getReceipts = jest.fn< + Promise>, + Parameters + >(); + const transport: ExpoPushTransport = { send: jest.fn(), getReceipts }; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + const savedRole = process.env.APP_ROLE; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SellerPushReceiptScheduler, + SellerPushDeliveryRepository, + SellerPushDeviceRepository, + { provide: ClockService, useValue: { now: () => NOW } }, + { provide: ConfigService, useValue: { getOrThrow: () => cfg } }, + { provide: AlertService, useValue: alerts }, + { provide: EXPO_PUSH_TRANSPORT, useValue: transport }, + ], + }); + scheduler = module.get(SellerPushReceiptScheduler); + prisma = p; + }); + afterAll(async () => { + if (savedRole === undefined) delete process.env.APP_ROLE; + else process.env.APP_ROLE = savedRole; + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + process.env.APP_ROLE = 'worker'; + cfg = { enabled: true, accessToken: 'tok', requestTimeoutMs: 1_000 }; + getReceipts.mockReset(); + getReceipts.mockResolvedValue({}); + alerts.notify.mockClear(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + async function ticketOk(ticketId: string, sentMinutesAgo: number) { + const device = await createSellerPushDevice(prisma); + const delivery = await createSellerPushDelivery(prisma, { + push_device_id: device.id, + status: 'TICKET_OK', + ticket_id: ticketId, + sent_at: minutesAgo(sentMinutesAgo), + }); + return { device, delivery }; + } + const deliveryOf = (id: bigint) => + prisma.sellerPushDelivery.findUniqueOrThrow({ where: { id } }); + const deviceOf = (id: bigint) => + prisma.sellerPushDevice.findUniqueOrThrow({ where: { id } }); + + it('15분이 지난 TICKET_OK만 조회하고 ok 영수증은 RECEIPT_OK로 닫는다 — 반증: 15분 미만은 조회하지 않는다', async () => { + const { delivery: due } = await ticketOk('t-due', 16); + const { delivery: fresh } = await ticketOk('t-fresh', 14); + getReceipts.mockResolvedValue({ 't-due': { status: 'ok' } }); + + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledTimes(1); + expect(getReceipts).toHaveBeenCalledWith(['t-due'], { + accessToken: 'tok', + timeoutMs: 1_000, + }); + expect(await deliveryOf(due.id)).toMatchObject({ + status: 'RECEIPT_OK', + receipt_checked_at: NOW, + }); + expect(await deliveryOf(fresh.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + }); + + it('대상이 없으면 Expo를 부르지 않는다', async () => { + await ticketOk('t-fresh', 1); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }); + + it('DeviceNotRegistered 영수증은 RECEIPT_ERROR로 남기고 디바이스를 비활성한다 — 다른 오류는 디바이스를 살려 둔다', async () => { + const gone = await ticketOk('t-gone', 20); + const big = await ticketOk('t-big', 20); + getReceipts.mockResolvedValue({ + 't-gone': { + status: 'error', + message: 'x', + details: { error: 'DeviceNotRegistered' }, + }, + 't-big': { + status: 'error', + message: 'y', + details: { error: 'MessageTooBig' }, + }, + }); + + await scheduler.checkReceipts(); + + expect(await deliveryOf(gone.delivery.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'DeviceNotRegistered', + receipt_checked_at: NOW, + }); + expect(await deviceOf(gone.device.id)).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(await deliveryOf(big.delivery.id)).toMatchObject({ + status: 'RECEIPT_ERROR', + error_code: 'MessageTooBig', + }); + expect((await deviceOf(big.device.id)).disabled_at).toBeNull(); + }); + + it('영수증이 없는 ticket은 24시간이 지나면 RECEIPT_UNKNOWN으로 닫고, 그 전에는 다음 틱에 다시 본다', async () => { + const { delivery: expired } = await ticketOk('t-expired', 24 * 60); + const { delivery: waiting } = await ticketOk('t-waiting', 24 * 60 - 1); + + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledWith( + ['t-expired', 't-waiting'], + expect.anything(), + ); + expect(await deliveryOf(expired.id)).toMatchObject({ + status: 'RECEIPT_UNKNOWN', + error_code: null, + receipt_checked_at: NOW, + }); + expect(await deliveryOf(waiting.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + + await scheduler.checkReceipts(); + expect(getReceipts).toHaveBeenLastCalledWith( + ['t-waiting'], + expect.anything(), + ); + }); + + it('조회가 던지면 삼키고 warn 경보를 내며 행은 그대로다', async () => { + const { delivery } = await ticketOk('t-1', 20); + getReceipts.mockRejectedValue(new Error('fetch failed')); + + await expect(scheduler.checkReceipts()).resolves.toBeUndefined(); + + expect(alerts.notify).toHaveBeenCalledWith({ + level: 'warn', + title: 'Expo 푸시 영수증 조회 실패', + key: 'expo-push:receipts', + detail: 'fetch failed', + }); + expect(Logger.prototype.warn).toHaveBeenCalled(); + expect(await deliveryOf(delivery.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + }); + + it('401은 인증 실패 경보(error)로 낸다', async () => { + await ticketOk('t-1', 20); + getReceipts.mockRejectedValue( + new ExpoPushAuthError(401, 'Expo 푸시 영수증 조회'), + ); + + await scheduler.checkReceipts(); + + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + }), + ); + }); + + it.each(['api', 'ws'] as const)( + '반증: %s 역할에서는 돌지 않는다', + async (role) => { + process.env.APP_ROLE = role; + await ticketOk('t-1', 20); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }, + ); + + it('EXPO_PUSH_ENABLED=false면 돌지 않는다', async () => { + cfg = { ...cfg, enabled: false }; + await ticketOk('t-1', 20); + + await scheduler.checkReceipts(); + + expect(getReceipts).not.toHaveBeenCalled(); + }); + + it('반증: 앞선 틱이 끝나기 전의 틱은 건너뛰고, 끝난 뒤에는 다시 돈다', async () => { + await ticketOk('t-1', 20); + let finish!: (value: Record) => void; + getReceipts.mockReturnValueOnce( + new Promise>((resolve) => { + finish = resolve; + }), + ); + + const first = scheduler.checkReceipts(); + // 첫 틱이 DB 조회를 지나 Expo 호출에 머무를 때까지 + while (getReceipts.mock.calls.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await scheduler.checkReceipts(); + expect(getReceipts).toHaveBeenCalledTimes(1); + + finish({ 't-1': { status: 'ok' } }); + await first; + await ticketOk('t-2', 20); + await scheduler.checkReceipts(); + + expect(getReceipts).toHaveBeenCalledTimes(2); + expect(getReceipts).toHaveBeenLastCalledWith(['t-2'], expect.anything()); + }); +}); diff --git a/src/features/notification/services/seller-push-receipt.scheduler.ts b/src/features/notification/services/seller-push-receipt.scheduler.ts new file mode 100644 index 00000000..0f2d0f08 --- /dev/null +++ b/src/features/notification/services/seller-push-receipt.scheduler.ts @@ -0,0 +1,118 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { ClockService } from '@/common/providers/clock.service'; +import { HOUR_MS } from '@/common/utils/kst-time'; +import { resolveAppRole, runsBackgroundJobs } from '@/config/app.config'; +import type { ExpoPushConfig } from '@/config/expo-push.config'; +import { + EXPO_ERROR_DEVICE_NOT_REGISTERED, + EXPO_ERROR_UNKNOWN, + PUSH_DEVICE_DISABLED_REASON, +} from '@/features/notification/constants/seller-push.constants'; +import { + type ReceiptResult, + SellerPushDeliveryRepository, +} from '@/features/notification/repositories/seller-push-delivery.repository'; +import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository'; +import { AlertService } from '@/global/alerting'; +import { + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + type ExpoPushTransport, +} from '@/global/expo-push'; + +/** Expo가 영수증을 준비하는 데 두는 여유. */ +export const RECEIPT_DELAY_MS = 15 * 60 * 1000; +/** 이 뒤에도 영수증이 없으면 RECEIPT_UNKNOWN으로 닫는다(Expo 보관 기한 24h). */ +export const RECEIPT_GIVE_UP_MS = 24 * HOUR_MS; +export const RECEIPT_BATCH_LIMIT = 300; + +/** + * Expo 영수증 조회(5분, worker). ticket만으로는 APNs/FCM 거절(DeviceNotRegistered 등)을 모르므로 뒤늦게 확인해 디바이스를 비활성한다. + * 실패는 던지지 않고 경보만 — 다음 틱이 같은 행을 다시 본다. + */ +@Injectable() +export class SellerPushReceiptScheduler { + private readonly logger = new Logger(SellerPushReceiptScheduler.name); + private running = false; + + constructor( + private readonly config: ConfigService, + private readonly deliveries: SellerPushDeliveryRepository, + private readonly devices: SellerPushDeviceRepository, + private readonly clock: ClockService, + private readonly alerts: AlertService, + @Inject(EXPO_PUSH_TRANSPORT) private readonly transport: ExpoPushTransport, + ) {} + + /** 크론(ScheduleModule)은 worker에만 실리지만 역할도 함께 본다 — api·ws에서 호출돼도 Expo를 부르지 않게. */ + @Cron(CronExpression.EVERY_5_MINUTES) + async checkReceipts(): Promise { + if (!runsBackgroundJobs(resolveAppRole())) return; + const cfg = this.config.getOrThrow('expoPush'); + if (!cfg.enabled || this.running) return; + this.running = true; + try { + await this.run(cfg); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.logger.warn(`Expo 푸시 영수증 조회 실패: ${detail}`); + await this.alerts.notify( + error instanceof ExpoPushAuthError + ? { + level: 'error', + title: 'Expo 푸시 인증 실패', + key: 'expo-push:auth', + detail: `${detail} — EXPO_PUSH_ACCESS_TOKEN 확인`, + } + : { + level: 'warn', + title: 'Expo 푸시 영수증 조회 실패', + key: 'expo-push:receipts', + detail, + }, + ); + } finally { + this.running = false; + } + } + + private async run(cfg: ExpoPushConfig): Promise { + const now = this.clock.now(); + const rows = await this.deliveries.listForReceipt({ + sentBefore: new Date(now.getTime() - RECEIPT_DELAY_MS), + limit: RECEIPT_BATCH_LIMIT, + }); + if (rows.length === 0) return; + const receipts = await this.transport.getReceipts( + rows.map((row) => row.ticket_id), + { accessToken: cfg.accessToken, timeoutMs: cfg.requestTimeoutMs }, + ); + const results: ReceiptResult[] = []; + const notRegistered: bigint[] = []; + for (const row of rows) { + const receipt = receipts[row.ticket_id]; + if (!receipt) { + if (now.getTime() - row.sent_at.getTime() >= RECEIPT_GIVE_UP_MS) + results.push({ id: row.id, status: 'RECEIPT_UNKNOWN' }); + continue; + } + if (receipt.status === 'ok') { + results.push({ id: row.id, status: 'RECEIPT_OK' }); + continue; + } + const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN; + results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode }); + if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) + notRegistered.push(row.push_device_id); + } + await this.deliveries.markReceipts(results, now); + await this.devices.disableByIds( + notRegistered, + PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, + now, + ); + } +} diff --git a/src/features/order/index.ts b/src/features/order/index.ts index a8f8fdf4..38228e43 100644 --- a/src/features/order/index.ts +++ b/src/features/order/index.ts @@ -10,5 +10,6 @@ export { // 주문 접수 이벤트 계약(outbox). 판매자 푸시 소비자(notification)가 읽는다. export { ORDER_SUBMITTED, + type OrderSubmittedPayload, parseOrderSubmittedPayload, } from '@/features/order/events/order-submitted.event'; diff --git a/src/global/expo-push/expo-push.transport.spec.ts b/src/global/expo-push/expo-push.transport.spec.ts new file mode 100644 index 00000000..637a7e22 --- /dev/null +++ b/src/global/expo-push/expo-push.transport.spec.ts @@ -0,0 +1,208 @@ +import { TimeoutError } from '@/common/utils/with-timeout'; +import { + createExpoPushTransport, + EXPO_PUSH_RECEIPTS_URL, + EXPO_PUSH_SEND_URL, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + expoPushTransport, +} from '@/global/expo-push/expo-push.transport'; + +const OPTIONS = { accessToken: 'tok', timeoutMs: 1_000 }; + +function message(seq: number): ExpoPushMessage { + return { + to: `ExponentPushToken[dev-${seq}]`, + title: '새 주문', + body: `본문 ${seq}`, + data: { kind: 'ORDER_SUBMITTED', orderId: String(seq) }, + channelId: 'default', + }; +} + +function reply(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +describe('expoPushTransport', () => { + const fetchFn = jest.fn, [string, RequestInit]>(); + const transport = createExpoPushTransport(fetchFn); + + beforeEach(() => fetchFn.mockReset()); + + describe('send', () => { + it('send 엔드포인트에 메시지 배열을 JSON으로 POST하고 ticket 배열을 돌려준다', async () => { + const messages = [message(1), message(2)]; + const tickets = [ + { status: 'ok', id: 't-1' }, + { + status: 'error', + message: 'gone', + details: { error: 'DeviceNotRegistered' }, + }, + ]; + fetchFn.mockResolvedValue(reply({ data: tickets })); + + await expect(transport.send(messages, OPTIONS)).resolves.toEqual(tickets); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(EXPO_PUSH_SEND_URL); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + authorization: 'Bearer tok', + }); + expect(JSON.parse(init.body as string)).toEqual(messages); + }); + + it('액세스 토큰이 없으면 Authorization 헤더를 붙이지 않는다', async () => { + fetchFn.mockResolvedValue(reply({ data: [{ status: 'ok', id: 't' }] })); + + await transport.send([message(1)], { ...OPTIONS, accessToken: null }); + + expect(fetchFn.mock.calls[0][1].headers).not.toHaveProperty( + 'authorization', + ); + }); + + it('반증: 101개는 요청 없이 던진다(100개는 보낸다)', async () => { + const hundred = Array.from({ length: 100 }, (_, i) => message(i)); + fetchFn.mockResolvedValue( + reply({ data: hundred.map((_, i) => ({ status: 'ok', id: `t${i}` })) }), + ); + await expect(transport.send(hundred, OPTIONS)).resolves.toHaveLength(100); + + await expect( + transport.send([...hundred, message(100)], OPTIONS), + ).rejects.toThrow('100개까지: 101'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it.each([400, 429, 500, 503])( + '비 2xx(%i)는 상태 코드를 담은 ExpoPushHttpError', + async (status) => { + fetchFn.mockResolvedValue(reply({ errors: [] }, status)); + + const error = await transport + .send([message(1)], OPTIONS) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ExpoPushHttpError); + expect(error).not.toBeInstanceOf(ExpoPushAuthError); + expect((error as ExpoPushHttpError).status).toBe(status); + expect((error as Error).message).toBe(`Expo 푸시 전송 HTTP ${status}`); + }, + ); + + it.each([401, 403])('%i은 ExpoPushAuthError', async (status) => { + fetchFn.mockResolvedValue(reply({}, status)); + + const error = await transport + .send([message(1)], OPTIONS) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ExpoPushAuthError); + expect((error as ExpoPushHttpError).status).toBe(status); + }); + + it('기한 안에 응답이 없으면 TimeoutError', async () => { + fetchFn.mockReturnValue(new Promise(() => undefined)); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('200이어도 errors가 있으면 요청 전체 거절로 던진다', async () => { + fetchFn.mockResolvedValue( + reply({ errors: [{ code: 'PUSH_TOO_MANY_EXPERIENCE_IDS' }] }), + ); + + await expect(transport.send([message(1)], OPTIONS)).rejects.toThrow( + 'PUSH_TOO_MANY_EXPERIENCE_IDS', + ); + }); + + it.each([ + ['data 없음', {}], + ['배열 아님', { data: { status: 'ok' } }], + ['수 불일치', { data: [{ status: 'ok', id: 't' }] }], + ])('반증: %s 응답은 형식 오류로 던진다', async (_label, body) => { + fetchFn.mockResolvedValue(reply(body)); + + await expect( + transport.send([message(1), message(2)], OPTIONS), + ).rejects.toThrow('응답 형식 오류'); + }); + }); + + describe('getReceipts', () => { + it('getReceipts 엔드포인트에 ids를 POST하고 ticket id → 영수증 맵을 돌려준다', async () => { + const receipts = { + 't-1': { status: 'ok' }, + 't-2': { + status: 'error', + message: 'x', + details: { error: 'MessageTooBig' }, + }, + }; + fetchFn.mockResolvedValue(reply({ data: receipts })); + + await expect( + transport.getReceipts(['t-1', 't-2'], OPTIONS), + ).resolves.toEqual(receipts); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(EXPO_PUSH_RECEIPTS_URL); + expect(JSON.parse(init.body as string)).toEqual({ ids: ['t-1', 't-2'] }); + expect(init.headers).toMatchObject({ authorization: 'Bearer tok' }); + }); + + it('반증: 1001개는 요청 없이 던진다', async () => { + const ids = Array.from({ length: 1_001 }, (_, i) => `t${i}`); + + await expect(transport.getReceipts(ids, OPTIONS)).rejects.toThrow( + '1000개까지: 1001', + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('401은 ExpoPushAuthError', async () => { + fetchFn.mockResolvedValue(reply({}, 401)); + + await expect( + transport.getReceipts(['t'], OPTIONS), + ).rejects.toBeInstanceOf(ExpoPushAuthError); + }); + + it.each([ + ['data 없음', {}], + ['배열', { data: [] }], + ])('반증: %s 응답은 형식 오류로 던진다', async (_label, body) => { + fetchFn.mockResolvedValue(reply(body)); + + await expect(transport.getReceipts(['t'], OPTIONS)).rejects.toThrow( + '응답 형식 오류', + ); + }); + }); + + it('기본 구현은 전역 fetch를 쓴다', async () => { + const spy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(reply({ data: { t: { status: 'ok' } } })); + try { + await expect( + expoPushTransport.getReceipts(['t'], OPTIONS), + ).resolves.toEqual({ t: { status: 'ok' } }); + expect(spy).toHaveBeenCalledWith( + EXPO_PUSH_RECEIPTS_URL, + expect.objectContaining({ method: 'POST' }), + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/global/expo-push/expo-push.transport.ts b/src/global/expo-push/expo-push.transport.ts new file mode 100644 index 00000000..256ea2c9 --- /dev/null +++ b/src/global/expo-push/expo-push.transport.ts @@ -0,0 +1,136 @@ +import { withTimeout } from '@/common/utils/with-timeout'; + +export const EXPO_PUSH_SEND_URL = 'https://exp.host/--/api/v2/push/send'; +export const EXPO_PUSH_RECEIPTS_URL = + 'https://exp.host/--/api/v2/push/getReceipts'; +/** Expo Push Service 요청당 상한 — 배치 분할은 호출자 책임이고 어댑터는 넘기면 던진다. */ +export const EXPO_PUSH_SEND_LIMIT = 100; +export const EXPO_PUSH_RECEIPT_LIMIT = 1_000; + +export interface ExpoPushMessage { + to: string; + title: string; + body: string; + data: Record; + channelId: string; +} + +export interface ExpoPushFailure { + status: 'error'; + message: string; + /** error가 Expo 오류 코드(DeviceNotRegistered 등) */ + details?: { error?: string }; +} +export type ExpoPushTicket = { status: 'ok'; id: string } | ExpoPushFailure; +export type ExpoPushReceipt = { status: 'ok' } | ExpoPushFailure; + +export interface ExpoPushRequestOptions { + accessToken: string | null; + timeoutMs: number; +} + +/** 전송 계약 — 소비자·스케줄러는 이 형태만 알고, 테스트는 가짜를 넣는다. */ +export interface ExpoPushTransport { + send( + messages: ExpoPushMessage[], + options: ExpoPushRequestOptions, + ): Promise; + getReceipts( + ticketIds: string[], + options: ExpoPushRequestOptions, + ): Promise>; +} + +export const EXPO_PUSH_TRANSPORT = Symbol('EXPO_PUSH_TRANSPORT'); + +export class ExpoPushHttpError extends Error { + constructor( + readonly status: number, + label: string, + ) { + super(`${label} HTTP ${status}`); + this.name = 'ExpoPushHttpError'; + } +} + +/** 401·403 — 액세스 토큰 폐기·오설정. 재시도로 풀리지 않으므로 호출자가 경보를 낸다. */ +export class ExpoPushAuthError extends ExpoPushHttpError { + constructor(status: number, label: string) { + super(status, label); + this.name = 'ExpoPushAuthError'; + } +} + +export type ExpoPushFetch = ( + url: string, + init: RequestInit, +) => Promise; + +export function createExpoPushTransport( + fetchFn: ExpoPushFetch = (url, init) => fetch(url, init), +): ExpoPushTransport { + async function post( + url: string, + body: unknown, + options: ExpoPushRequestOptions, + label: string, + ): Promise { + const headers: Record = { + accept: 'application/json', + 'content-type': 'application/json', + }; + if (options.accessToken) + headers.authorization = `Bearer ${options.accessToken}`; + const response = await withTimeout( + fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body) }), + options.timeoutMs, + label, + ); + if (response.status === 401 || response.status === 403) + throw new ExpoPushAuthError(response.status, label); + if (!response.ok) throw new ExpoPushHttpError(response.status, label); + const json = (await response.json()) as { + data?: unknown; + errors?: unknown; + }; + // 요청 전체 거절은 200에 errors로 온다(PUSH_TOO_MANY_EXPERIENCE_IDS 등) + if (json.errors !== undefined) + throw new Error(`${label} 거절: ${JSON.stringify(json.errors)}`); + return json.data; + } + + return { + async send(messages, options) { + if (messages.length > EXPO_PUSH_SEND_LIMIT) { + throw new Error( + `Expo 푸시 전송은 한 번에 ${EXPO_PUSH_SEND_LIMIT}개까지: ${messages.length}`, + ); + } + const label = 'Expo 푸시 전송'; + const data = await post(EXPO_PUSH_SEND_URL, messages, options, label); + // ticket은 요청 순서대로 온다 — 수가 다르면 디바이스 매핑을 믿을 수 없다 + if (!Array.isArray(data) || data.length !== messages.length) + throw new Error(`${label} 응답 형식 오류`); + return data as ExpoPushTicket[]; + }, + async getReceipts(ticketIds, options) { + if (ticketIds.length > EXPO_PUSH_RECEIPT_LIMIT) { + throw new Error( + `Expo 푸시 영수증 조회는 한 번에 ${EXPO_PUSH_RECEIPT_LIMIT}개까지: ${ticketIds.length}`, + ); + } + const label = 'Expo 푸시 영수증 조회'; + const data = await post( + EXPO_PUSH_RECEIPTS_URL, + { ids: ticketIds }, + options, + label, + ); + if (typeof data !== 'object' || data === null || Array.isArray(data)) + throw new Error(`${label} 응답 형식 오류`); + return data as Record; + }, + }; +} + +export const expoPushTransport = createExpoPushTransport(); diff --git a/src/global/expo-push/index.ts b/src/global/expo-push/index.ts new file mode 100644 index 00000000..b51607f2 --- /dev/null +++ b/src/global/expo-push/index.ts @@ -0,0 +1,11 @@ +export { + EXPO_PUSH_SEND_LIMIT, + EXPO_PUSH_TRANSPORT, + ExpoPushAuthError, + ExpoPushHttpError, + type ExpoPushMessage, + type ExpoPushReceipt, + type ExpoPushTicket, + type ExpoPushTransport, + expoPushTransport, +} from '@/global/expo-push/expo-push.transport'; diff --git a/src/global/metrics/metrics.service.spec.ts b/src/global/metrics/metrics.service.spec.ts index 17b1b661..67e315bb 100644 --- a/src/global/metrics/metrics.service.spec.ts +++ b/src/global/metrics/metrics.service.spec.ts @@ -19,6 +19,7 @@ describe('MetricsService', () => { ['type', 'field', 'outcome'], ], ['caquick_outbox_consume_duration_seconds', ['consumer', 'result']], + ['caquick_expo_push_sends_total', ['result']], ['caquick_metrics_collect_errors_total', ['gauge']], ['caquick_process_cpu_seconds_total', []], ['caquick_nodejs_heap_size_used_bytes', []], diff --git a/src/global/metrics/metrics.service.ts b/src/global/metrics/metrics.service.ts index 5045cefe..92d7f7ee 100644 --- a/src/global/metrics/metrics.service.ts +++ b/src/global/metrics/metrics.service.ts @@ -54,6 +54,13 @@ export class MetricsService { registers: [this.registry], }); + readonly expoPushSends = new Counter({ + name: 'caquick_expo_push_sends_total', + help: '판매자 푸시 전송 메시지 수. result = TICKET_OK | TICKET_ERROR | AUTH_ERROR(인증 실패로 전송되지 않은 메시지).', + labelNames: ['result'] as const, + registers: [this.registry], + }); + /** collect 실패는 /metrics를 죽이지 않고 여기에 쌓인다 — 장애 중에도 나머지 지표는 나가야 한다. */ readonly collectErrors = new Counter({ name: 'caquick_metrics_collect_errors_total', From 32520f4752e2abbc608a89a66f76fe6853250192 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 01:11:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Expo=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20abo?= =?UTF-8?q?rt=C2=B7=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=88=9C=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 2라운드 리뷰 반영. - 전송 어댑터: withTimeout이 fetch를 취소하지 않아 기한 초과 뒤에도 원 POST가 완료돼 재시도와 겹치면 중복 발송될 수 있고, 헤더 도착 뒤 response.json()은 기한 밖이라 무한 대기가 가능했다. AbortController를 fetch에 넘기고 기한이 바디(JSON) 읽기까지 덮게 한 뒤 TimeoutError면 abort한다. 던지는 에러는 기존과 같은 TimeoutError(fetch가 AbortError로 끝나도 호출자는 TimeoutError). - 소비자·영수증 스케줄러: markTickets/markReceipts 뒤에 disableByIds를 하던 순서를 뒤집는다. 전달 행을 종료 상태로 바꾼 뒤 죽으면 재시도·다음 틱이 그 행을 다시 보지 않아 DeviceNotRegistered 디바이스가 영영 비활성되지 않았다. disableByIds는 멱등(disabled_at IS NULL 조건)이라 먼저 해도 안전하다. - 회귀 테스트 5건: 전송 3(기한 초과 시 signal abort·AbortError여도 TimeoutError, 바디 읽기 지연도 기한에 걸림, 제때 끝나면 abort 안 함), 소비자 1(markTickets가 던져도 디바이스는 이미 비활성·행은 PENDING), 스케줄러 1(markReceipts가 던져도 디바이스는 이미 비활성·행은 다음 틱 대상). 운영 코드를 HEAD로 되돌리면 이 5건만 실패하는 것을 확인. --- .../seller-push-outbox.consumer.spec.ts | 30 +++++++++++++++ .../services/seller-push-outbox.consumer.ts | 3 +- .../seller-push-receipt.scheduler.spec.ts | 30 +++++++++++++++ .../services/seller-push-receipt.scheduler.ts | 3 +- .../expo-push/expo-push.transport.spec.ts | 37 +++++++++++++++++++ src/global/expo-push/expo-push.transport.ts | 34 ++++++++++++++--- 6 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/features/notification/services/seller-push-outbox.consumer.spec.ts b/src/features/notification/services/seller-push-outbox.consumer.spec.ts index 9de29aa1..1a335a0c 100644 --- a/src/features/notification/services/seller-push-outbox.consumer.spec.ts +++ b/src/features/notification/services/seller-push-outbox.consumer.spec.ts @@ -73,6 +73,7 @@ function okTickets(messages: ExpoPushMessage[]): ExpoPushTicket[] { // 판매자 푸시 전송 — 매장 디바이스 fan-out, 재전달 멱등(ticket 기록 행 제외), 배치 100, ticket 오류·인증 실패 처리. describe('SellerPushOutboxConsumer (real DB)', () => { let consumer: SellerPushOutboxConsumer; + let deliveryRepository: SellerPushDeliveryRepository; let metrics: MetricsService; let prisma: PrismaClient; let cfg: ExpoPushConfig; @@ -100,6 +101,7 @@ describe('SellerPushOutboxConsumer (real DB)', () => { ], }); consumer = module.get(SellerPushOutboxConsumer); + deliveryRepository = module.get(SellerPushDeliveryRepository); metrics = module.get(MetricsService); prisma = p; }); @@ -266,6 +268,34 @@ describe('SellerPushOutboxConsumer (real DB)', () => { expect(Logger.prototype.warn).toHaveBeenCalled(); }); + it('ticket 기록(markTickets)이 던져도 DeviceNotRegistered 디바이스는 이미 비활성이다', async () => { + const { storeId, devices } = await storeWithDevices(1); + send.mockResolvedValue([ + { + status: 'error', + message: 'not registered', + details: { error: 'DeviceNotRegistered' }, + }, + ]); + jest + .spyOn(deliveryRepository, 'markTickets') + .mockRejectedValueOnce(new Error('db down')); + + await expect(consumer.handle(orderSubmitted(storeId))).rejects.toThrow( + 'db down', + ); + + expect( + await prisma.sellerPushDevice.findUniqueOrThrow({ + where: { id: devices[0].id }, + }), + ).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect((await deliveries())[0].status).toBe('PENDING'); + }); + it('오류 코드가 없는 ticket 오류는 UNKNOWN으로 남기고 디바이스는 살려 둔다', async () => { const { storeId, devices } = await storeWithDevices(1); send.mockResolvedValue([{ status: 'error', message: 'too big' }]); diff --git a/src/features/notification/services/seller-push-outbox.consumer.ts b/src/features/notification/services/seller-push-outbox.consumer.ts index 8763493b..2c9b9e89 100644 --- a/src/features/notification/services/seller-push-outbox.consumer.ts +++ b/src/features/notification/services/seller-push-outbox.consumer.ts @@ -130,12 +130,13 @@ export class SellerPushOutboxConsumer implements OutboxConsumer { if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) notRegistered.push(row.push_device_id); }); - await this.deliveries.markTickets(results); + // 비활성(멱등)이 먼저 — 행을 종료 상태로 바꾼 뒤 죽으면 재시도가 PENDING을 못 찾아 디바이스가 영영 남는다 await this.devices.disableByIds( notRegistered, PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, sentAt, ); + await this.deliveries.markTickets(results); for (const result of results) { this.metrics.expoPushSends.inc({ result: result.status }); } diff --git a/src/features/notification/services/seller-push-receipt.scheduler.spec.ts b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts index 42b0084e..597d4976 100644 --- a/src/features/notification/services/seller-push-receipt.scheduler.spec.ts +++ b/src/features/notification/services/seller-push-receipt.scheduler.spec.ts @@ -30,6 +30,7 @@ const minutesAgo = (minutes: number) => // 영수증 조회 — 15분 지난 TICKET_OK만, 오류면 디바이스 비활성, 24h 넘게 영수증이 없으면 UNKNOWN으로 닫는다. 실패는 경보만. describe('SellerPushReceiptScheduler (real DB)', () => { let scheduler: SellerPushReceiptScheduler; + let deliveryRepository: SellerPushDeliveryRepository; let prisma: PrismaClient; let cfg: ExpoPushConfig; const getReceipts = jest.fn< @@ -53,6 +54,7 @@ describe('SellerPushReceiptScheduler (real DB)', () => { ], }); scheduler = module.get(SellerPushReceiptScheduler); + deliveryRepository = module.get(SellerPushDeliveryRepository); prisma = p; }); afterAll(async () => { @@ -151,6 +153,34 @@ describe('SellerPushReceiptScheduler (real DB)', () => { expect((await deviceOf(big.device.id)).disabled_at).toBeNull(); }); + it('영수증 기록(markReceipts)이 던져도 DeviceNotRegistered 디바이스는 이미 비활성이고 행은 다음 틱에 다시 본다', async () => { + const gone = await ticketOk('t-gone', 20); + getReceipts.mockResolvedValue({ + 't-gone': { + status: 'error', + message: 'x', + details: { error: 'DeviceNotRegistered' }, + }, + }); + jest + .spyOn(deliveryRepository, 'markReceipts') + .mockRejectedValueOnce(new Error('db down')); + + await scheduler.checkReceipts(); + + expect(await deviceOf(gone.device.id)).toMatchObject({ + disabled_at: NOW, + disabled_reason: 'DEVICE_NOT_REGISTERED', + }); + expect(await deliveryOf(gone.delivery.id)).toMatchObject({ + status: 'TICKET_OK', + receipt_checked_at: null, + }); + expect(alerts.notify).toHaveBeenCalledWith( + expect.objectContaining({ key: 'expo-push:receipts', detail: 'db down' }), + ); + }); + it('영수증이 없는 ticket은 24시간이 지나면 RECEIPT_UNKNOWN으로 닫고, 그 전에는 다음 틱에 다시 본다', async () => { const { delivery: expired } = await ticketOk('t-expired', 24 * 60); const { delivery: waiting } = await ticketOk('t-waiting', 24 * 60 - 1); diff --git a/src/features/notification/services/seller-push-receipt.scheduler.ts b/src/features/notification/services/seller-push-receipt.scheduler.ts index 0f2d0f08..5db8fa81 100644 --- a/src/features/notification/services/seller-push-receipt.scheduler.ts +++ b/src/features/notification/services/seller-push-receipt.scheduler.ts @@ -108,11 +108,12 @@ export class SellerPushReceiptScheduler { if (errorCode === EXPO_ERROR_DEVICE_NOT_REGISTERED) notRegistered.push(row.push_device_id); } - await this.deliveries.markReceipts(results, now); + // 비활성(멱등)이 먼저 — 행을 닫은 뒤 죽으면 다음 틱이 그 행을 다시 보지 않아 디바이스가 영영 남는다 await this.devices.disableByIds( notRegistered, PUSH_DEVICE_DISABLED_REASON.DEVICE_NOT_REGISTERED, now, ); + await this.deliveries.markReceipts(results, now); } } diff --git a/src/global/expo-push/expo-push.transport.spec.ts b/src/global/expo-push/expo-push.transport.spec.ts index 637a7e22..924e09a4 100644 --- a/src/global/expo-push/expo-push.transport.spec.ts +++ b/src/global/expo-push/expo-push.transport.spec.ts @@ -115,6 +115,43 @@ describe('expoPushTransport', () => { ).rejects.toBeInstanceOf(TimeoutError); }); + it('기한을 넘기면 fetch에 넘긴 signal을 abort한다 — fetch가 AbortError로 끝나도 TimeoutError를 던진다', async () => { + fetchFn.mockImplementation( + (_url, init) => + new Promise((_, reject) => { + init.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(true); + }); + + it('헤더는 바로 와도 바디(JSON) 읽기가 기한을 넘기면 TimeoutError — 기한은 바디까지 덮는다', async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: () => new Promise(() => undefined), + } as unknown as Response); + + await expect( + transport.send([message(1)], { ...OPTIONS, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(TimeoutError); + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(true); + }); + + it('제때 끝난 요청의 signal은 abort하지 않는다', async () => { + fetchFn.mockResolvedValue(reply({ data: [{ status: 'ok', id: 't' }] })); + + await transport.send([message(1)], OPTIONS); + + expect(fetchFn.mock.calls[0][1].signal?.aborted).toBe(false); + }); + it('200이어도 errors가 있으면 요청 전체 거절로 던진다', async () => { fetchFn.mockResolvedValue( reply({ errors: [{ code: 'PUSH_TOO_MANY_EXPERIENCE_IDS' }] }), diff --git a/src/global/expo-push/expo-push.transport.ts b/src/global/expo-push/expo-push.transport.ts index 256ea2c9..a87aa761 100644 --- a/src/global/expo-push/expo-push.transport.ts +++ b/src/global/expo-push/expo-push.transport.ts @@ -1,4 +1,4 @@ -import { withTimeout } from '@/common/utils/with-timeout'; +import { TimeoutError, withTimeout } from '@/common/utils/with-timeout'; export const EXPO_PUSH_SEND_URL = 'https://exp.host/--/api/v2/push/send'; export const EXPO_PUSH_RECEIPTS_URL = @@ -81,11 +81,33 @@ export function createExpoPushTransport( }; if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`; - const response = await withTimeout( - fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body) }), - options.timeoutMs, - label, - ); + // 기한은 헤더가 아니라 바디(JSON)까지 덮고, 넘기면 진행 중인 POST를 끊는다 — 재시도와 겹쳐 중복 발송되지 않게 + const controller = new AbortController(); + try { + return await withTimeout( + read(url, headers, body, controller.signal, label), + options.timeoutMs, + label, + ); + } catch (error) { + if (error instanceof TimeoutError) controller.abort(); + throw error; + } + } + + async function read( + url: string, + headers: Record, + body: unknown, + signal: AbortSignal, + label: string, + ): Promise { + const response = await fetchFn(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }); if (response.status === 401 || response.status === 403) throw new ExpoPushAuthError(response.status, label); if (!response.ok) throw new ExpoPushHttpError(response.status, label); From 3a7d013574ed288ec37cb88b265dd5ed8b5bcb88 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 01:20:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20roles-coverage=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 뒤 develop SDL 기준으로 seller·admin 루트 필드 수 갱신. --- src/test/roles-coverage.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/roles-coverage.spec.ts b/src/test/roles-coverage.spec.ts index 189eb15b..383e3d8f 100644 --- a/src/test/roles-coverage.spec.ts +++ b/src/test/roles-coverage.spec.ts @@ -13,7 +13,7 @@ describe('접두 루트 필드 인가 커버리지', () => { const handlerAuth = collectHandlerAuth(collectFeatureResolverClasses()); describe.each([ - ['seller', 'SELLER', 55], + ['seller', 'SELLER', 56], ['admin', 'ADMIN', 59], ] as const)('%s 접두 → @Roles(%s)', (prefix, role, count) => { const fields = collectRootFieldsWithPrefix(prefix);