From 773ce34c58fb47fbedf3812d2c2bb3b141656854 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 11 Aug 2026 15:18:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EC=BA=A1=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/recap-log-domain-contract.md | 20 +++- openapi/soundlog-api.yaml | 102 ++---------------- .../migration.sql | 1 + prisma/models/travel.prisma | 1 - prisma/seed.ts | 3 - src/controllers/me.controller.ts | 5 - src/controllers/moment-log.controller.ts | 42 ++++++++ src/mock/mock-db.ts | 2 - src/routes/index.ts | 9 +- src/services/mock-soundlog.service.ts | 20 +--- src/services/soundlog.service.ts | 20 +--- src/validators/api.validators.ts | 11 +- tests/api.test.ts | 89 ++++++++++----- 14 files changed, 138 insertions(+), 190 deletions(-) create mode 100644 prisma/migrations/20260811000000_server_first_recap_writes/migration.sql diff --git a/README.md b/README.md index 65aacbb..dd33a69 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SoundLog React Native/Expo 앱과 연동되는 Express + TypeScript API 서버입니다. -API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 61개 HTTP 연산을 제공합니다. +API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 62개 HTTP 연산을 제공합니다. 리캡, 여행 로그, 여행 세션, GPS 경로를 변경할 때는 [Recap / Log 서버 도메인 계약](docs/recap-log-domain-contract.md)을 먼저 확인합니다. @@ -136,7 +136,6 @@ pnpm db:seed # 로컬 seed 데이터 적재 - `POST /v1/auth/logout` - `GET /v1/me` - `PATCH /v1/me/profile` - - `POST /v1/me/migrate-local-data` - Tour / Home / Playlists - `GET /v1/tour/nearby-places` - `GET /v1/tour/reverse-geocode` diff --git a/docs/recap-log-domain-contract.md b/docs/recap-log-domain-contract.md index 00d541e..cde0288 100644 --- a/docs/recap-log-domain-contract.md +++ b/docs/recap-log-domain-contract.md @@ -42,10 +42,13 @@ API/DB 호환성 때문에 기술 이름을 즉시 바꾸지 않더라도 제품 ### Standalone Recap -1. 클라이언트가 `sessionId` 없이 Product Recap을 저장한다. -2. 서버는 독립 리캡 원본을 저장한다. -3. 공유/지도용 서버 `Recap` row가 필요하면 `sessionId = null`로 생성할 수 있다. -4. 이 결과는 Product Log 목록에서 제외한다. +1. 신규 클라이언트가 `POST /v1/recap-captures`에 `sessionId` 없이 `createStandaloneRecap: true`를 보내 Product Recap을 저장한다. +2. 서버는 독립 리캡 원본과 공유 및 지도용 서버 `Recap` row를 함께 만든다. +3. 응답은 독립 리캡의 공유 식별자를 `recapId`로 반환한다. +4. 같은 idempotency key로 재요청하면 기존 원본과 공유 데이터를 반환한다. +5. 이 결과는 Product Log 목록에서 제외한다. + +기존 클라이언트는 이 옵션을 보내지 않으므로 서버가 원본만 만들고 기존의 후속 `POST /v1/recaps` 요청을 처리한다. 신규 클라이언트가 이전 서버를 호출해 응답에 `recapId`가 없으면 같은 멱등성 키로 후속 요청을 한 번 수행한다. 이 호환 계약으로 앱과 서버의 배포 순서에 따른 중복 리캡을 막는다. ### Travel Log @@ -54,7 +57,14 @@ API/DB 호환성 때문에 기술 이름을 즉시 바꾸지 않더라도 제품 3. 세션 중 `routePoints`를 동기화한다. 4. 여행 종료 시 같은 `sessionId`의 Product Recap ID만 사용해 서버 `Recap` row를 생성한다. 5. 서버는 일반 여행 로그의 `Recap.travelSessionId`를 여행 세션과 1:1로 저장하고 외부 DTO에는 `sessionId`로 응답한다. -6. 오프라인에서 만든 로컬 세션은 소유한 Product Recap이 확인될 때 종료 세션으로 복구한 뒤 Log를 만든다. +6. 서버 저장에 실패하면 클라이언트는 여행 종료를 완료하지 않고 사용자가 다시 시도할 수 있게 한다. + +## Server-first policy + +- 서버 저장이 끝난 Product Recap과 Product Log만 사용자 기록으로 취급한다. +- 클라이언트의 로컬 기록 이관 API와 저장 대기 큐를 서버 계약에 포함하지 않는다. +- 서버에 저장된 `MomentLog`는 별도의 동기화 상태를 갖지 않는다. 이전 앱 배포 호환을 위한 응답의 `syncStatus: synced` 상수는 한시적으로 유지한다. +- 활성 여행의 GPS 경로 버퍼는 센서 데이터 복구 목적으로 기기에 남을 수 있지만 서버 기록을 대신하지 않는다. ## Read flow diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index 85f35a3..e5b94d0 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -25,7 +25,7 @@ tags: - name: Auth description: 간편 로그인 및 토큰 관리 - name: Me - description: 사용자 프로필과 로컬 데이터 마이그레이션 + description: 사용자 프로필과 계정 데이터 관리 - name: Tour description: 관광공사 데이터 기반 장소 컨텍스트 - name: Home @@ -294,35 +294,6 @@ paths: "401": $ref: "#/components/responses/Unauthorized" - /v1/me/migrate-local-data: - post: - tags: - - Me - summary: 로그인 전 로컬 데이터 계정 이관 - description: | - 로그인 전 이 기기에 저장된 순간 로그, 좋아요/저장 음악, Recap 초안을 로그인 계정으로 이관합니다. - 동일한 `Idempotency-Key`로 재시도해도 중복 저장되지 않아야 합니다. - operationId: migrateLocalData - parameters: - - $ref: "#/components/parameters/IdempotencyKey" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/LocalDataMigrationRequest" - responses: - "200": - description: 이관 처리 결과 - content: - application/json: - schema: - $ref: "#/components/schemas/LocalDataMigrationResponse" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - /v1/tour/places: get: tags: @@ -756,7 +727,7 @@ paths: summary: 리캡 캡처 생성 description: | 카메라 버튼으로 촬영한 사진, 현재 위치, 현재 장소, 재생 중인 음악, 무드 태그를 하나의 리캡으로 저장합니다. - 여행모드 중 `sessionId`가 포함되면 해당 여행 로그에 묶이고, `sessionId`가 없으면 낱개 리캡으로 남습니다. + 여행모드 중 `sessionId`가 포함되면 해당 여행 로그에 묶입니다. `sessionId`가 없고 `createStandaloneRecap`이 true이면 독립 리캡 공유 데이터를 함께 만들고 응답의 `recapId`로 반환합니다. MVP 저장 정책상 사진, 위치, 장소, 곡은 모두 선택값입니다. 음악이나 위치가 없어도 리캡 캡처는 생성할 수 있습니다. 사진 업로드는 `multipart/form-data`를 기본으로 정의합니다. operationId: createRecapCapture @@ -2769,6 +2740,9 @@ components: type: string format: binary description: 카메라 촬영 이미지 파일. 사진 없이 음악/장소 리캡만 저장할 때는 생략할 수 있습니다. + createStandaloneRecap: + type: boolean + description: 독립 캡처의 리캡 공유 데이터까지 한 요청에서 생성할지 여부입니다. 새 앱이 true로 전송하며 생략하면 기존 클라이언트 방식으로 캡처만 생성합니다. createdAt: type: string format: date-time @@ -2877,12 +2851,14 @@ components: - createdAt - moodTags - recapVisibility - - syncStatus - templateId properties: id: type: string example: moment_001 + recapId: + type: string + description: 여행모드 밖에서 만든 독립 리캡의 공유 식별자입니다. 여행 세션에 속한 리캡은 여행 종료 전까지 이 값이 없습니다. photoUrl: type: string format: uri @@ -2920,11 +2896,10 @@ components: - camera syncStatus: type: string + deprecated: true + description: 이전 앱 버전과의 배포 호환을 위해 한시적으로 반환하는 상수입니다. 신규 클라이언트는 사용하지 않습니다. enum: - - pending - synced - - failed - example: synced RecommendationEventType: type: string @@ -2937,7 +2912,6 @@ components: - track_save - track_unsave - moment_log_saved - - moment_log_sync_failed - playlist_open - mood_adjusted - mood_filter_change @@ -3968,54 +3942,6 @@ components: type: string description: 무효화할 refresh token. 없으면 클라이언트 로컬 세션만 정리합니다. - LocalDataMigrationRequest: - type: object - required: - - idempotencyKey - properties: - idempotencyKey: - type: string - maxLength: 128 - description: 중복 이관 방지용 클라이언트 생성 키 - momentLogCount: - type: integer - minimum: 0 - default: 0 - libraryTrackCount: - type: integer - minimum: 0 - default: 0 - recapDraftCount: - type: integer - minimum: 0 - default: 0 - - LocalDataMigrationResult: - type: object - required: - - accepted - - migrated - properties: - accepted: - type: boolean - example: true - migrated: - type: object - required: - - momentLogCount - - libraryTrackCount - - recapDraftCount - properties: - momentLogCount: - type: integer - example: 3 - libraryTrackCount: - type: integer - example: 6 - recapDraftCount: - type: integer - example: 1 - AuthToken: type: object required: @@ -4122,14 +4048,6 @@ components: profile: $ref: "#/components/schemas/UserProfile" - LocalDataMigrationResponse: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/LocalDataMigrationResult" - LibraryTrackListResponse: type: object required: diff --git a/prisma/migrations/20260811000000_server_first_recap_writes/migration.sql b/prisma/migrations/20260811000000_server_first_recap_writes/migration.sql new file mode 100644 index 0000000..0f6ec1e --- /dev/null +++ b/prisma/migrations/20260811000000_server_first_recap_writes/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "MomentLog" DROP COLUMN "syncStatus"; diff --git a/prisma/models/travel.prisma b/prisma/models/travel.prisma index d4b487c..20ba171 100644 --- a/prisma/models/travel.prisma +++ b/prisma/models/travel.prisma @@ -31,7 +31,6 @@ model MomentLog { travelMode String? moodTags String[] source String - syncStatus String visibility String @default("private") user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/prisma/seed.ts b/prisma/seed.ts index dfd4b38..ae0f2f4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -787,7 +787,6 @@ export async function seedDemoCommunity() { placeName: capture.placeName, sessionId: log.sessionId, source: 'camera', - syncStatus: 'synced', templateId: capture.templateId, trackSnapshot: createDemoTrackSnapshot(track), travelMode: log.travelMode, @@ -844,7 +843,6 @@ export async function seedDemoCommunity() { photoUrl: capture.imageUrl, placeName: capture.placeName, source: 'camera', - syncStatus: 'synced', templateId: capture.templateId, trackSnapshot: createDemoTrackSnapshot(track), userId: user.id, @@ -988,7 +986,6 @@ export async function seedDatabase() { note: log.note, moodTags: [...log.moodTags], source: 'camera', - syncStatus: 'synced', templateId: log.templateId, travelMode: log.travelMode, visibility: log.visibility, diff --git a/src/controllers/me.controller.ts b/src/controllers/me.controller.ts index 69114bb..71e492a 100644 --- a/src/controllers/me.controller.ts +++ b/src/controllers/me.controller.ts @@ -25,9 +25,4 @@ export const meController = { const user = requireUser(req); res.json(dataResponse(await apiService.upsertMyProfile(user.id, req.body))); }, - - async migrateLocalData(req: Request, res: Response) { - const user = requireUser(req); - res.json(dataResponse(await apiService.migrateLocalData(user.id, req.body))); - }, }; diff --git a/src/controllers/moment-log.controller.ts b/src/controllers/moment-log.controller.ts index d11a478..1f7b9dd 100644 --- a/src/controllers/moment-log.controller.ts +++ b/src/controllers/moment-log.controller.ts @@ -33,6 +33,48 @@ export const momentLogController = { ); }, + async createRecapCapture(req: Request, res: Response) { + const user = requireUser(req); + const photoPath = req.file + ? createUploadedFilePublicPath(req.file.filename) + : undefined; + const idempotencyKey = req.header('Idempotency-Key'); + const capture = await apiService.createMomentLog( + user.id, + { + ...req.body, + photoPath, + }, + idempotencyKey, + ); + + if (req.body.sessionId || !req.body.createStandaloneRecap) { + res.status(201).json(dataResponse(capture)); + return; + } + + if (!capture.id) { + throw new Error('Created recap capture did not return an id.'); + } + + const recap = await apiService.createRecap( + user.id, + { + momentLogIds: [capture.id], + templateId: req.body.templateId ?? 'film', + visibility: req.body.visibility ?? 'private', + }, + `standalone-recap:${idempotencyKey ?? capture.id}`, + ); + + res.status(201).json( + dataResponse({ + ...capture, + recapId: recap.id, + }), + ); + }, + async updateMomentLog(req: Request, res: Response) { const user = requireUser(req); diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 7b7af75..2f3b065 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -32,7 +32,6 @@ type MockMomentLog = { note?: string; sessionId?: string; source: 'camera'; - syncStatus: 'failed' | 'pending' | 'synced'; templateId: string; trackSnapshot?: MockTrack; travelMode?: string; @@ -254,7 +253,6 @@ function createMockDb() { note: log.note, moodTags: [...log.moodTags], source: 'camera' as const, - syncStatus: 'synced' as const, templateId: log.templateId, trackSnapshot: trackById.get(log.trackId), travelMode: log.travelMode, diff --git a/src/routes/index.ts b/src/routes/index.ts index ce99e3a..8e301a1 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -76,13 +76,6 @@ export function createApiRouter() { validate({ body: meValidators.profileBody }), asyncHandler(meController.upsertProfile), ); - router.post( - '/v1/me/migrate-local-data', - authMiddleware, - validate({ body: meValidators.migrationBody }), - asyncHandler(meController.migrateLocalData), - ); - router.get( '/v1/tour/places', authMiddleware, @@ -170,7 +163,7 @@ export function createApiRouter() { authMiddleware, momentPhotoUpload.single('photo'), validate({ body: momentLogValidators.createBody }), - asyncHandler(momentLogController.createMomentLog), + asyncHandler(momentLogController.createRecapCapture), ); router.patch( '/v1/recap-captures/:momentLogId', diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 527bba3..4be9958 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -233,7 +233,7 @@ function momentLogToDto(log: (typeof mockDb.momentLogs)[number]) { travelMode: log.travelMode, moodTags: log.moodTags, source: log.source, - syncStatus: log.syncStatus, + syncStatus: 'synced' as const, }); } @@ -945,23 +945,6 @@ export const mockSoundlogService = { return { deleted: true }; }, - async migrateLocalData(_userId: string, input: { - idempotencyKey: string; - libraryTrackCount: number; - momentLogCount: number; - recapDraftCount: number; - }) { - return { - accepted: true, - idempotencyKey: input.idempotencyKey, - migrated: { - libraryTrackCount: input.libraryTrackCount, - momentLogCount: input.momentLogCount, - recapDraftCount: input.recapDraftCount, - }, - }; - }, - async getNearbyPlaces(params: { lat: number; limit?: number; @@ -1373,7 +1356,6 @@ export const mockSoundlogService = { travelMode: input.travelMode, moodTags: input.moodTags, source: 'camera' as const, - syncStatus: 'synced' as const, visibility: input.visibility ?? 'private', }; diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index b67b83e..6ca0adb 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -1231,7 +1231,7 @@ function momentLogToDto(log: MomentLog) { travelMode: log.travelMode ?? undefined, moodTags: log.moodTags, source: log.source, - syncStatus: log.syncStatus, + syncStatus: 'synced' as const, }); } @@ -1762,23 +1762,6 @@ export const soundlogService = { return { deleted: true }; }, - async migrateLocalData(_userId: string, input: { - idempotencyKey: string; - libraryTrackCount: number; - momentLogCount: number; - recapDraftCount: number; - }) { - return { - accepted: true, - idempotencyKey: input.idempotencyKey, - migrated: { - libraryTrackCount: input.libraryTrackCount, - momentLogCount: input.momentLogCount, - recapDraftCount: input.recapDraftCount, - }, - }; - }, - async getNearbyPlaces(params: { contentTypes?: string; lat: number; @@ -2250,7 +2233,6 @@ export const soundlogService = { travelMode: input.travelMode, moodTags: input.moodTags, source: 'camera', - syncStatus: 'synced', visibility: input.visibility ?? 'private', }, }); diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index f3466bd..ea12bcd 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -149,12 +149,6 @@ export const meValidators = { preferredMoods: z.array(z.string()), travelStyles: z.array(z.string()), }), - migrationBody: z.object({ - idempotencyKey: z.string().min(1).max(128), - libraryTrackCount: z.number().int().min(0).optional().default(0), - momentLogCount: z.number().int().min(0).optional().default(0), - recapDraftCount: z.number().int().min(0).optional().default(0), - }), }; export const tourValidators = { @@ -256,6 +250,10 @@ export const momentLogValidators = { }), createBody: z.object({ artistName: z.string().optional(), + createStandaloneRecap: z + .union([z.boolean(), z.enum(['true', 'false'])]) + .transform((value) => value === true || value === 'true') + .optional(), createdAt: z.string().datetime(), lat: z.coerce.number().optional(), lng: z.coerce.number().optional(), @@ -310,7 +308,6 @@ export const recommendationEventValidators = { 'track_save', 'track_unsave', 'moment_log_saved', - 'moment_log_sync_failed', 'playlist_open', 'mood_adjusted', 'mood_filter_change', diff --git a/tests/api.test.ts b/tests/api.test.ts index 166ae72..308b32b 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -281,24 +281,17 @@ describe('Soundlog API', () => { expect(wrongPassword.body.error.code).toBe('UNAUTHORIZED'); }); - it('returns account summary, migrates local data, and logs out', async () => { + it('returns account summary, rejects the removed local migration route, and logs out', async () => { const me = await request(app).get('/v1/me').set('Authorization', authHeader); expect(me.status).toBe(200); expect(me.body.data.user.id).toEqual(expect.any(String)); expect(me.body.data.profile).toBeDefined(); - const migration = await request(app) + const removedMigration = await request(app) .post('/v1/me/migrate-local-data') .set('Authorization', authHeader) - .send({ - idempotencyKey: `test-migration-${Date.now()}`, - libraryTrackCount: 2, - momentLogCount: 3, - recapDraftCount: 1, - }); - expect(migration.status).toBe(200); - expect(migration.body.data.accepted).toBe(true); - expect(migration.body.data.migrated.momentLogCount).toBe(3); + .send({}); + expect(removedMigration.status).toBe(404); const login = await request(app).post('/v1/auth/register').send({ email: `logout-${Date.now()}@soundlog.test`, @@ -726,11 +719,13 @@ describe('Soundlog API', () => { expect(minimalMoment.body.data.moodTags).toEqual([]); expect(minimalMoment.body.data.syncStatus).toBe('synced'); + const recapCaptureIdempotencyKey = `recap-capture-${Date.now()}`; const aliasCreated = await request(app) .post('/v1/recap-captures') .set('Authorization', authHeader) - .set('Idempotency-Key', `recap-capture-${Date.now()}`) + .set('Idempotency-Key', recapCaptureIdempotencyKey) .field('createdAt', new Date().toISOString()) + .field('createStandaloneRecap', 'true') .field('moodTags', 'fresh') .field('note', '새 리캡 캡처 경로 테스트') .field('placeName', '리캡 캡처 테스트 장소') @@ -741,6 +736,51 @@ describe('Soundlog API', () => { }); expect(aliasCreated.status).toBe(201); expect(aliasCreated.body.data.note).toBe('새 리캡 캡처 경로 테스트'); + expect(aliasCreated.body.data.recapId).toEqual(expect.any(String)); + + const aliasDuplicate = await request(app) + .post('/v1/recap-captures') + .set('Authorization', authHeader) + .set('Idempotency-Key', recapCaptureIdempotencyKey) + .field('createdAt', new Date().toISOString()) + .field('createStandaloneRecap', 'true') + .field('moodTags', 'calm') + .field('placeName', '중복 리캡 캡처'); + expect(aliasDuplicate.status).toBe(201); + expect(aliasDuplicate.body.data.id).toBe(aliasCreated.body.data.id); + expect(aliasDuplicate.body.data.recapId).toBe(aliasCreated.body.data.recapId); + + const standaloneRecap = await request(app) + .get(`/v1/recaps/${aliasCreated.body.data.recapId}/share`) + .set('Authorization', authHeader); + expect(standaloneRecap.status).toBe(200); + expect(standaloneRecap.body.data.moments).toHaveLength(1); + expect(standaloneRecap.body.data.moments[0].id).toBe(aliasCreated.body.data.id); + + const legacyCapture = await request(app) + .post('/v1/recap-captures') + .set('Authorization', authHeader) + .set('Idempotency-Key', `legacy-recap-capture-${Date.now()}`) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'calm'); + expect(legacyCapture.status).toBe(201); + expect(legacyCapture.body.data.recapId).toBeUndefined(); + + const legacyRecap = await request(app) + .post('/v1/recaps') + .set('Authorization', authHeader) + .set('Idempotency-Key', `legacy-recap-${legacyCapture.body.data.id}`) + .send({ + momentLogIds: [legacyCapture.body.data.id], + templateId: 'album', + }); + expect(legacyRecap.status).toBe(201); + + const legacyRecapShare = await request(app) + .get(`/v1/recaps/${legacyRecap.body.data.id}/share`) + .set('Authorization', authHeader); + expect(legacyRecapShare.status).toBe(200); + expect(legacyRecapShare.body.data.moments[0].id).toBe(legacyCapture.body.data.id); const aliasUpdated = await request(app) .patch(`/v1/recap-captures/${aliasCreated.body.data.id}`) @@ -911,17 +951,7 @@ describe('Soundlog API', () => { playlistId: 'seoul-night', context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, createdAt: new Date().toISOString(), - value: 'pending', - }, - { - id: `event-moment-sync-failed-${Date.now()}`, - sessionId: 'seed-session', - type: 'moment_log_sync_failed', - trackId: 'seoul-city', - playlistId: 'seoul-night', - context: { moodFilter: '잔잔한', placeName: '서울 야경 산책' }, - createdAt: new Date().toISOString(), - value: 'network_error', + value: 'server', }, ], }); @@ -1259,17 +1289,22 @@ describe('Soundlog API', () => { trackTitle: '한강에서', }); expect(JSON.stringify(mixedVisibilityMarker)).not.toContain('비공개 장소 이름'); + expect( + publicMarkersAfterUpdate.body.data.some( + (marker: { recapId: string }) => marker.recapId === farCreated.body.data.id, + ), + ).toBe(false); - const fixedRadiusMarkers = await request(app) + const expandedRadiusMarkers = await request(app) .get('/v1/recap-markers') .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 5000, scope: 'public' }) .set('Authorization', authHeader); - expect(fixedRadiusMarkers.status).toBe(200); + expect(expandedRadiusMarkers.status).toBe(200); expect( - fixedRadiusMarkers.body.data.some( + expandedRadiusMarkers.body.data.some( (marker: { recapId: string }) => marker.recapId === farCreated.body.data.id, ), - ).toBe(false); + ).toBe(true); const duplicate = await request(app) .post('/v1/recaps')