From 70931b71ea1b916e95718d785efd28c841414721 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 21 May 2026 01:47:21 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(user):=20MePayload=EC=97=90=20linkedId?= =?UTF-8?q?entities=20GraphQL=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소셜 로그인 provider(GOOGLE/KAKAO) 정보를 프론트에서 확인할 수 있도록 IdentityProvider enum과 LinkedIdentity 타입을 노출한다. 한 계정에 여러 identity 연결 가능성을 고려해 배열로 정의하고, lastLoginAt까지 포함해 "최근 로그인 provider" UI 구성도 가능하게 한다. --- src/features/user/user-profile.graphql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/features/user/user-profile.graphql b/src/features/user/user-profile.graphql index a52b9f5..0899f3c 100644 --- a/src/features/user/user-profile.graphql +++ b/src/features/user/user-profile.graphql @@ -30,6 +30,22 @@ type MePayload { accountType: AccountType! """프로필 정보""" profile: UserProfile! + """연동된 소셜 로그인 식별자 목록(soft-deleted 제외, 최근 로그인 순)""" + linkedIdentities: [LinkedIdentity!]! +} + +"""소셜 로그인 Provider 종류""" +enum IdentityProvider { + GOOGLE + KAKAO +} + +"""유저 계정에 연동된 소셜 로그인 식별자""" +type LinkedIdentity { + """소셜 로그인 Provider""" + provider: IdentityProvider! + """해당 provider로 마지막 로그인한 시각(없을 수 있음)""" + lastLoginAt: DateTime } """유저 프로필""" From f5d057648674dae3314d373a668b3af42c18d9f7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 21 May 2026 01:49:59 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(user):=20MePayload=EC=97=90=20linkedId?= =?UTF-8?q?entities=20=EB=A7=A4=ED=95=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserRepository.findAccountWithProfile에서 account_identities를 함께 조회(soft-deleted 제외, last_login_at desc 정렬)하도록 include를 추가하고, toMePayload에서 provider/lastLoginAt만 추려 노출한다. provider_subject나 provider_email 등 OIDC 내부 식별자는 의도적으로 노출하지 않는다. --- .../user/repositories/user.repository.ts | 28 +++++++++++++------ .../user/services/user-base.service.ts | 5 ++++ src/features/user/types/user-output.type.ts | 12 +++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index b5f8d55..70de2eb 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -2,12 +2,18 @@ import { Injectable } from '@nestjs/common'; import { AccountType, CustomDraftStatus, + IdentityProvider, NotificationEvent, NotificationType, } from '@prisma/client'; import { PrismaService } from '@/prisma'; +export interface UserAccountIdentity { + provider: IdentityProvider; + last_login_at: Date | null; +} + export interface UserAccountWithProfile { id: bigint; account_type: AccountType; @@ -22,6 +28,7 @@ export interface UserAccountWithProfile { onboarding_completed_at: Date | null; deleted_at: Date | null; } | null; + account_identities: UserAccountIdentity[]; } @Injectable() @@ -59,17 +66,22 @@ export class UserRepository { accountId: bigint, options?: { withDeleted?: boolean }, ): Promise { - const where = { - id: accountId, - ...(options?.withDeleted ? { deleted_at: undefined } : {}), - }; - const args = { - where, + return this.prisma.account.findFirst({ + where: { + id: accountId, + ...(options?.withDeleted ? { deleted_at: undefined } : {}), + }, include: { user_profile: true, + // soft-deleted identity는 노출 대상 아님. 최근 로그인 순으로 정렬해 + // FE가 "최근 로그인 provider" 표시할 때 별도 정렬 없이 사용 가능. + account_identities: { + where: { deleted_at: null }, + orderBy: [{ last_login_at: 'desc' }, { id: 'asc' }], + select: { provider: true, last_login_at: true }, + }, }, - }; - return this.prisma.account.findFirst(args); + }); } async isNicknameTaken( diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index dd8709b..0eec15c 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -60,6 +60,11 @@ export abstract class UserBaseService { profileImageUrl: account.user_profile.profile_image_url, onboardingCompletedAt: account.user_profile.onboarding_completed_at, }, + // repository에서 soft-deleted 제외 + 최근 로그인 순으로 정렬해 가져온다. + linkedIdentities: account.account_identities.map((identity) => ({ + provider: identity.provider, + lastLoginAt: identity.last_login_at, + })), }; } diff --git a/src/features/user/types/user-output.type.ts b/src/features/user/types/user-output.type.ts index 248e434..08189ff 100644 --- a/src/features/user/types/user-output.type.ts +++ b/src/features/user/types/user-output.type.ts @@ -1,4 +1,8 @@ -import type { AccountType, NotificationType } from '@prisma/client'; +import type { + AccountType, + IdentityProvider, + NotificationType, +} from '@prisma/client'; export interface UserProfileOutput { nickname: string; @@ -8,12 +12,18 @@ export interface UserProfileOutput { onboardingCompletedAt: Date | null; } +export interface LinkedIdentityOutput { + provider: IdentityProvider; + lastLoginAt: Date | null; +} + export interface MePayload { accountId: string; email: string | null; name: string | null; accountType: AccountType; profile: UserProfileOutput; + linkedIdentities: LinkedIdentityOutput[]; } export interface ViewerCounts { From 639ecce8aaa90c72742ffd39078a56f619dd0540 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 21 May 2026 01:54:45 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(user):=20linkedIdentities=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91/=EB=85=B8=EC=B6=9C=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserProfileService.me에 4가지 케이스(빈 배열/단일/다건 정렬/soft-deleted 제외) + resolver 단에서 새 필드가 그대로 노출되는 통합 케이스 1건을 추가한다. 모두 real DB로 검증. --- .../resolvers/user-profile.resolver.spec.ts | 22 ++++- .../services/user-profile.service.spec.ts | 96 ++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/features/user/resolvers/user-profile.resolver.spec.ts b/src/features/user/resolvers/user-profile.resolver.spec.ts index 7f40f07..3c17069 100644 --- a/src/features/user/resolvers/user-profile.resolver.spec.ts +++ b/src/features/user/resolvers/user-profile.resolver.spec.ts @@ -8,7 +8,11 @@ import { UserProfileService } from '@/features/user/services/user-profile.servic import { S3Service } from '@/global/storage/s3.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; -import { createAccount, createUserProfile } from '@/test/factories'; +import { + createAccount, + createAccountIdentity, + createUserProfile, +} from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; /** @@ -77,6 +81,22 @@ describe('User Profile Resolvers (real DB)', () => { UnauthorizedException, ); }); + + it('linkedIdentities 필드까지 resolver를 통해 그대로 노출된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'KAKAO', + }); + + const result = await queryResolver.me({ + accountId: account.id.toString(), + }); + + expect(result.linkedIdentities).toHaveLength(1); + expect(result.linkedIdentities[0].provider).toBe('KAKAO'); + }); }); describe('Mutation.updateMyProfile', () => { diff --git a/src/features/user/services/user-profile.service.spec.ts b/src/features/user/services/user-profile.service.spec.ts index f0711c8..a22ab44 100644 --- a/src/features/user/services/user-profile.service.spec.ts +++ b/src/features/user/services/user-profile.service.spec.ts @@ -10,7 +10,11 @@ import { UserProfileService } from '@/features/user/services/user-profile.servic import { S3Service } from '@/global/storage/s3.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; -import { createAccount, createUserProfile } from '@/test/factories'; +import { + createAccount, + createAccountIdentity, + createUserProfile, +} from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; describe('UserProfileService (real DB)', () => { @@ -74,6 +78,96 @@ describe('UserProfileService (real DB)', () => { UnauthorizedException, ); }); + + it('연동된 identity가 없으면 linkedIdentities는 빈 배열이다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + + const result = await service.me(account.id); + + expect(result.linkedIdentities).toEqual([]); + }); + + it('연동된 identity가 있으면 provider/lastLoginAt을 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const identity = await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'GOOGLE', + }); + const loggedInAt = new Date('2025-01-15T10:00:00Z'); + await prisma.accountIdentity.update({ + where: { id: identity.id }, + data: { last_login_at: loggedInAt }, + }); + + const result = await service.me(account.id); + + expect(result.linkedIdentities).toEqual([ + { provider: 'GOOGLE', lastLoginAt: loggedInAt }, + ]); + }); + + it('여러 provider 연동 시 last_login_at desc 순으로 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + + const oldLogin = new Date('2025-01-01T00:00:00Z'); + const recentLogin = new Date('2025-03-01T00:00:00Z'); + + const google = await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'GOOGLE', + }); + const kakao = await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'KAKAO', + }); + await prisma.accountIdentity.update({ + where: { id: google.id }, + data: { last_login_at: oldLogin }, + }); + await prisma.accountIdentity.update({ + where: { id: kakao.id }, + data: { last_login_at: recentLogin }, + }); + + const result = await service.me(account.id); + + expect(result.linkedIdentities).toEqual([ + { provider: 'KAKAO', lastLoginAt: recentLogin }, + { provider: 'GOOGLE', lastLoginAt: oldLogin }, + ]); + }); + + it('soft-deleted identity는 linkedIdentities에서 제외된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + + const activeIdentity = await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'GOOGLE', + }); + const deletedIdentity = await createAccountIdentity(prisma, { + account_id: account.id, + provider: 'KAKAO', + }); + await prisma.accountIdentity.update({ + where: { id: deletedIdentity.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.me(account.id); + + expect(result.linkedIdentities).toHaveLength(1); + expect(result.linkedIdentities[0]).toMatchObject({ provider: 'GOOGLE' }); + // soft-deleted 식별자가 우연히 노출되지 않는지 명시적으로 검증 + expect(result.linkedIdentities.some((i) => i.provider === 'KAKAO')).toBe( + false, + ); + // 의도된 활성 identity가 실제 DB row와 매칭되는지 확인 + expect(activeIdentity.provider).toBe('GOOGLE'); + }); }); // ─── completeOnboarding ─── From 59ba8ca2bdd00cfd7532a8ec343cde4d77add749 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 21 May 2026 02:04:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(user):=20UserRepository.findAccountWit?= =?UTF-8?q?hProfile=20withDeleted=20contract=20spec=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호출부(UserBaseService.requireActiveUser)가 항상 withDeleted:true로 호출하여 서비스 spec으로는 falsy 브랜치가 도달되지 않는다. soft-delete extension과의 상호작용 contract를 명시적으로 못박는다. codecov/patch 80% 미달(66.67%) 해소 목적도 겸한다. --- .../user/repositories/user.repository.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/features/user/repositories/user.repository.spec.ts diff --git a/src/features/user/repositories/user.repository.spec.ts b/src/features/user/repositories/user.repository.spec.ts new file mode 100644 index 0000000..3c18cdf --- /dev/null +++ b/src/features/user/repositories/user.repository.spec.ts @@ -0,0 +1,75 @@ +import type { PrismaClient } from '@prisma/client'; + +import { UserRepository } from '@/features/user/repositories/user.repository'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createUserProfile } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * 본 spec은 UserRepository 중 "서비스/리졸버 spec으로는 직접 도달이 어려운" + * API contract만 좁게 검증한다. 일반적인 비즈니스 분기는 service spec에서 + * 다룬다는 컨벤션을 깨지 않기 위한 의도. + */ +describe('UserRepository (real DB)', () => { + let repo: UserRepository; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [UserRepository], + }); + repo = module.get(UserRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + // ───────────────────────────────────────────── + // findAccountWithProfile - withDeleted 플래그 contract + // + // 호출부(UserBaseService.requireActiveUser)가 항상 withDeleted:true로 + // 호출하기 때문에 서비스 spec으로는 falsy 브랜치가 검증되지 않는다. + // soft-delete extension(applySoftDeleteArgs)이 where에 deleted_at own-key + // 유무로 자동 필터 주입 여부를 분기하므로, 그 상호작용을 여기서 못박는다. + // ───────────────────────────────────────────── + describe('findAccountWithProfile - withDeleted flag', () => { + it('withDeleted 미지정이면 soft-deleted 계정은 null로 반환된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + await prisma.account.update({ + where: { id: account.id }, + data: { deleted_at: new Date() }, + }); + + const result = await repo.findAccountWithProfile(account.id); + + expect(result).toBeNull(); + }); + + it('withDeleted: true 이면 soft-deleted 계정도 그대로 반환된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const deletedAt = new Date(); + await prisma.account.update({ + where: { id: account.id }, + data: { deleted_at: deletedAt }, + }); + + const result = await repo.findAccountWithProfile(account.id, { + withDeleted: true, + }); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(account.id); + expect(result?.deleted_at).not.toBeNull(); + }); + }); +});