Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/features/user/repositories/user.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
28 changes: 20 additions & 8 deletions src/features/user/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +28,7 @@ export interface UserAccountWithProfile {
onboarding_completed_at: Date | null;
deleted_at: Date | null;
} | null;
account_identities: UserAccountIdentity[];
}

@Injectable()
Expand Down Expand Up @@ -59,17 +66,22 @@ export class UserRepository {
accountId: bigint,
options?: { withDeleted?: boolean },
): Promise<UserAccountWithProfile | null> {
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(
Expand Down
22 changes: 21 additions & 1 deletion src/features/user/resolvers/user-profile.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/features/user/services/user-base.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
};
}

Expand Down
96 changes: 95 additions & 1 deletion src/features/user/services/user-profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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 ───
Expand Down
12 changes: 11 additions & 1 deletion src/features/user/types/user-output.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { AccountType, NotificationType } from '@prisma/client';
import type {
AccountType,
IdentityProvider,
NotificationType,
} from '@prisma/client';

export interface UserProfileOutput {
nickname: string;
Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions src/features/user/user-profile.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

"""유저 프로필"""
Expand Down
Loading