From fbe4fb468506112e73ebf2cafe944270121df39f Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 11 Nov 2024 17:48:12 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20oauthId=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20int=EC=97=90=EC=84=9C=20decimal=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/user/domain/user.entity.ts | 4 ++-- packages/backend/src/user/user.service.spec.ts | 2 +- packages/backend/src/user/user.service.ts | 7 +++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 92f22831..de6f0007 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -28,8 +28,8 @@ export class User { @Column({ length: 10, default: OauthType.LOCAL }) type: OauthType = OauthType.LOCAL; - @Column({ name: 'oauth_id' }) - oauthId?: number; + @Column('decimal', { name: 'oauth_id' }) + oauthId?: string; @Column({ name: 'is_light', default: true }) isLight: boolean = true; diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index 8b7a1b66..6f80adf4 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -17,7 +17,7 @@ describe('UserService 테스트', () => { email: 'test@naver.com', type: OauthType.GOOGLE, nickname: 'test', - oauthId: 1, + oauthId: '123123231242141', }; test('유저를 생성한다', async () => { diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 9986d6c0..215d4bcc 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -12,7 +12,7 @@ export class UserService { constructor(private readonly dataSources: DataSource) {} async register({ nickname, email, type, oauthId }: RegisterRequest) { - const user = await this.dataSources.transaction(async (manager) => { + return await this.dataSources.transaction(async (manager) => { await this.validateUserExists(type, oauthId, manager); return await manager.save(User, { nickname, @@ -21,10 +21,9 @@ export class UserService { oauthId, }); }); - return { nickname: user.nickname, email: user.email, type: user.type }; } - async findUserByOauthIdAndType(oauthId: number, type: OauthType) { + async findUserByOauthIdAndType(oauthId: string, type: OauthType) { return await this.dataSources.manager.findOne(User, { where: { oauthId, type }, }); @@ -32,7 +31,7 @@ export class UserService { private async validateUserExists( type: OauthType, - oauthId: number, + oauthId: string, manager: EntityManager, ) { if (await manager.exists(User, { where: { oauthId, type } })) { From 899910a59c75283ba39e4e3d072d251e9a6e245c Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 11 Nov 2024 17:53:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat:=20=EA=B5=AC=EA=B8=80=20oa?= =?UTF-8?q?uth=20=EC=9E=90=EB=8F=99=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/auth/auth.module.ts | 9 +++-- packages/backend/src/auth/google.strategy.ts | 34 +++++++++++-------- .../backend/src/auth/googleAuth.service.ts | 33 ++++++++++++++++++ packages/backend/src/user/user.module.ts | 1 + 4 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 packages/backend/src/auth/googleAuth.service.ts diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index 659e92f2..d8bb299f 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; -import { GoogleAuthController } from '@/auth/googleAuth.controller'; import { GoogleStrategy } from '@/auth/google.strategy'; +import { GoogleAuthController } from '@/auth/googleAuth.controller'; +import { GoogleAuthService } from '@/auth/googleAuth.service'; +import { UserModule } from '@/user/user.module'; @Module({ + imports: [UserModule], controllers: [GoogleAuthController], - providers: [GoogleStrategy], + providers: [GoogleStrategy, GoogleAuthService], }) -export class AuthModule {} \ No newline at end of file +export class AuthModule {} diff --git a/packages/backend/src/auth/google.strategy.ts b/packages/backend/src/auth/google.strategy.ts index 4eb9c648..d1d85c8a 100644 --- a/packages/backend/src/auth/google.strategy.ts +++ b/packages/backend/src/auth/google.strategy.ts @@ -1,10 +1,20 @@ -import { Inject, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; -import { Logger } from 'winston'; +import { GoogleAuthService } from '@/auth/googleAuth.service'; +import { OauthType } from '@/user/domain/ouathType'; +export interface OauthUserInfo { + type: OauthType; + oauthId: string; + email?: string; + givenName?: string; + familyName?: string; +} + +@Injectable() export class GoogleStrategy extends PassportStrategy(Strategy) { - constructor(@Inject('winston') private readonly logger: Logger) { + constructor(private readonly googleAuthService: GoogleAuthService) { super({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, @@ -27,21 +37,15 @@ export class GoogleStrategy extends PassportStrategy(Strategy) { done: VerifyCallback, ) { const { id, emails, name, provider } = profile; - if (!emails) { - done(new UnauthorizedException('email is required'), false); - return; - } - if (!name) { - done(new UnauthorizedException('name is required'), false); - return; - } + const userInfo = { - type: provider, + type: provider.toUpperCase() as OauthType, oauthId: id, - emails: emails[0].value, - nickname: `${name.givenName} ${name.familyName}`, + email: emails?.[0].value, + givenName: name?.givenName, + familyName: name?.familyName, }; - this.logger.info(`google user info: ${JSON.stringify(userInfo)}`); + const user = await this.googleAuthService.attemptAuthentication(userInfo); done(null, false); } } diff --git a/packages/backend/src/auth/googleAuth.service.ts b/packages/backend/src/auth/googleAuth.service.ts new file mode 100644 index 00000000..a07a0037 --- /dev/null +++ b/packages/backend/src/auth/googleAuth.service.ts @@ -0,0 +1,33 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { OauthUserInfo } from '@/auth/google.strategy'; +import { UserService } from '@/user/user.service'; + +@Injectable() +export class GoogleAuthService { + constructor(private readonly userService: UserService) {} + + async attemptAuthentication(userInfo: OauthUserInfo) { + const { email, givenName, familyName, oauthId, type } = userInfo; + console.log(email); + const user = await this.userService.findUserByOauthIdAndType(oauthId, type); + if (user) { + return user; + } + if (!email) { + new UnauthorizedException('email is required'); + } + if (!givenName && !familyName) { + new UnauthorizedException('name is required'); + } + return await this.userService.register({ + type, + nickname: this.createName(givenName, familyName), + email: email as string, + oauthId, + }); + } + + private createName(givenName?: string, familyName?: string) { + return `${givenName ? givenName : ''}${familyName ? ` ${familyName}` : ''}`; + } +} diff --git a/packages/backend/src/user/user.module.ts b/packages/backend/src/user/user.module.ts index 0ec9cf3a..2a684173 100644 --- a/packages/backend/src/user/user.module.ts +++ b/packages/backend/src/user/user.module.ts @@ -6,5 +6,6 @@ import { UserService } from '@/user/user.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UserService], + exports: [UserService], }) export class UserModule {} From 07113735d424cd0243abb8e622db4dfdf9b665a6 Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 11 Nov 2024 17:57:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=85=20test:=20stock=20service=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20logger=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/stock/stock.service.spec.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index cc371fb6..50b76446 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -1,7 +1,14 @@ import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; import { StockService } from './stock.service'; import { createDataSourceMock } from '@/user/user.service.spec'; +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), +} as unknown as Logger; + describe('StockService 테스트', () => { const stockId = 'A005930'; const userId = 1; @@ -13,7 +20,7 @@ describe('StockService 테스트', () => { increment: jest.fn().mockResolvedValue({ id: stockId, views: 1 }), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await stockService.increaseView(stockId); @@ -25,7 +32,7 @@ describe('StockService 테스트', () => { exists: jest.fn().mockResolvedValue(false), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await expect(async () => stockService.increaseView('1')).rejects.toThrow( 'stock not found', @@ -41,7 +48,7 @@ describe('StockService 테스트', () => { insert: jest.fn(), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await stockService.createUserStock(userId, stockId); @@ -54,7 +61,7 @@ describe('StockService 테스트', () => { exists: jest.fn().mockResolvedValue(false), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await expect(() => stockService.createUserStock(userId, 'A'), @@ -66,7 +73,7 @@ describe('StockService 테스트', () => { exists: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(true), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await expect(async () => stockService.createUserStock(userId, stockId), @@ -79,7 +86,7 @@ describe('StockService 테스트', () => { delete: jest.fn(), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await stockService.deleteUserStock(userId, userStockId); @@ -92,7 +99,7 @@ describe('StockService 테스트', () => { findOne: jest.fn().mockResolvedValue(null), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await expect(() => stockService.deleteUserStock(userId, 2)).rejects.toThrow( 'user stock not found', @@ -105,7 +112,7 @@ describe('StockService 테스트', () => { findOne: jest.fn().mockResolvedValue({ user: { id: userId } }), }; const dataSource = createDataSourceMock(managerMock); - const stockService = new StockService(dataSource as DataSource); + const stockService = new StockService(dataSource as DataSource, logger); await expect(() => stockService.deleteUserStock(notOwnerUserId, userStockId), From 9c0dd4cf3afdd4d6a3ac80586cce944e091a745d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 11 Nov 2024 20:08:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20feat:=20dateEmbedded=20timestam?= =?UTF-8?q?p=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/common/dateEmbedded.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/common/dateEmbedded.entity.ts b/packages/backend/src/common/dateEmbedded.entity.ts index 3f418f59..2a0c9dd8 100644 --- a/packages/backend/src/common/dateEmbedded.entity.ts +++ b/packages/backend/src/common/dateEmbedded.entity.ts @@ -1,9 +1,9 @@ import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; export class DateEmbedded { - @CreateDateColumn({ name: 'created_at' }) + @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) createdAt?: Date; - @UpdateDateColumn({ name: 'updated_at' }) + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) updatedAt?: Date; } From 20afb94cfc74a9a9a49e6d504df08753cfc7527d Mon Sep 17 00:00:00 2001 From: kimminsu Date: Mon, 11 Nov 2024 20:38:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=88=84=EB=9D=BD=EC=8B=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=B0=9C=EC=83=9D=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EB=B0=8F=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/auth/googleAuth.service.spec.ts | 97 +++++++++++++++++++ .../backend/src/auth/googleAuth.service.ts | 7 +- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/auth/googleAuth.service.spec.ts diff --git a/packages/backend/src/auth/googleAuth.service.spec.ts b/packages/backend/src/auth/googleAuth.service.spec.ts new file mode 100644 index 00000000..9ba7419f --- /dev/null +++ b/packages/backend/src/auth/googleAuth.service.spec.ts @@ -0,0 +1,97 @@ +import { OauthUserInfo } from '@/auth/google.strategy'; +import { GoogleAuthService } from '@/auth/googleAuth.service'; +import { OauthType } from '@/user/domain/ouathType'; +import { Role } from '@/user/domain/role'; +import { User } from '@/user/domain/user.entity'; +import { UserService } from '@/user/user.service'; + +describe('GoogleAuthService 테스트', () => { + const userInfo: OauthUserInfo = { + type: OauthType.GOOGLE, + givenName: 'Homer Jay', + familyName: 'Simpson', + email: 'tester@naver.com', + oauthId: '12345678910', + }; + + test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => { + const user: User = { + id: 1, + role: Role.USER, + type: OauthType.GOOGLE, + isLight: true, + }; + const userService: Partial = { + findUserByOauthIdAndType: jest.fn().mockResolvedValue(user), + }; + const authService = new GoogleAuthService(userService as UserService); + + const authUser = await authService.attemptAuthentication(userInfo); + + expect(authUser).toBe(user); + }); + + test('자동 회원가입시 이메일이 없으면 예외가 발생한다.', async () => { + const userInfo: OauthUserInfo = { + type: OauthType.GOOGLE, + givenName: 'Homer Jay', + familyName: 'Simpson', + oauthId: '12345678910', + }; + const userService: Partial = { + findUserByOauthIdAndType: jest.fn().mockResolvedValue(null), + }; + const authService = new GoogleAuthService(userService as UserService); + + await expect(() => + authService.attemptAuthentication(userInfo), + ).rejects.toThrow('email is required'); + }); + + test('자동 회원가입시 givenName과 FamilyName이 없으면 예외가 발생한다.', async () => { + const userInfo: OauthUserInfo = { + type: OauthType.GOOGLE, + email: 'tester@naver.com', + oauthId: '12345678910', + }; + const userService: Partial = { + findUserByOauthIdAndType: jest.fn().mockResolvedValue(null), + }; + const authService = new GoogleAuthService(userService as UserService); + + await expect(() => + authService.attemptAuthentication(userInfo), + ).rejects.toThrow('name is required'); + }); + + test.each([ + ['Homer Jay', 'Simpson', 'Homer Jay Simpson'], + ['Homer Jay', undefined, 'Homer Jay'], + [undefined, 'Simpson', 'Simpson'], + ])( + '이름을 자동 생성 후 가입한다.', + async (givenName, familyName, expected) => { + const userInfo: OauthUserInfo = { + type: OauthType.GOOGLE, + givenName, + familyName, + email: 'tester@naver.com', + oauthId: '12345678910', + }; + const userService: Partial = { + findUserByOauthIdAndType: jest.fn().mockResolvedValue(null), + register: jest.fn(), + }; + const authService = new GoogleAuthService(userService as UserService); + + await authService.attemptAuthentication(userInfo); + + expect(userService.register).toHaveBeenCalledWith({ + type: userInfo.type, + nickname: expected, + email: userInfo.email, + oauthId: userInfo.oauthId, + }); + }, + ); +}); diff --git a/packages/backend/src/auth/googleAuth.service.ts b/packages/backend/src/auth/googleAuth.service.ts index a07a0037..f953859c 100644 --- a/packages/backend/src/auth/googleAuth.service.ts +++ b/packages/backend/src/auth/googleAuth.service.ts @@ -8,16 +8,15 @@ export class GoogleAuthService { async attemptAuthentication(userInfo: OauthUserInfo) { const { email, givenName, familyName, oauthId, type } = userInfo; - console.log(email); const user = await this.userService.findUserByOauthIdAndType(oauthId, type); if (user) { return user; } if (!email) { - new UnauthorizedException('email is required'); + throw new UnauthorizedException('email is required'); } if (!givenName && !familyName) { - new UnauthorizedException('name is required'); + throw new UnauthorizedException('name is required'); } return await this.userService.register({ type, @@ -28,6 +27,6 @@ export class GoogleAuthService { } private createName(givenName?: string, familyName?: string) { - return `${givenName ? givenName : ''}${familyName ? ` ${familyName}` : ''}`; + return `${givenName ? `${givenName} ` : ''}${familyName ? familyName : ''}`.trim(); } }