From 0ab8cfc4a85238633d962c534dca013026f719b5 Mon Sep 17 00:00:00 2001 From: "jinho.choi1010@gmail.com" Date: Tue, 17 Feb 2026 20:23:59 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Phase=205-6=20=E2=80=94=20Shared=20?= =?UTF-8?q?Types=20=ED=86=B5=ED=95=A9,=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EA=B2=BD=ED=99=94,=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B0=95=ED=99=94,=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20109=EA=B1=B4,=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94,=20=ED=8F=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5: - 5A: @etp/shared 타입 통합 — WebSocket 이벤트 타입, API 응답 타입 정의, 프론트엔드 5개 페이지 로컬 인터페이스를 shared 타입으로 교체 - 5B: 라우터 경화 — 404 NotFound 페이지, RouteErrorBoundary, App.tsx catch-all 라우트 추가 - 5C: 대시보드 최근 거래 피드 — backend getRecentTrades 엔드포인트, 한국어 상대시간 유틸, 실시간 WebSocket 피드 - 5D: 프론트엔드 테스트 0건→109건 — UI 컴포넌트 6개, 페이지 4개, 스토어/서비스 5개 테스트 파일 Phase 6 (진행 중): - 6A: 백엔드 보안 강화 — 비밀번호 강도 검증(@Matches 데코레이터), 엔드포인트별 Rate Limiting(로그인 5회/분, DID 3회/분), 환경변수 시작 시 검증(class-validator), DID privateKey 클라이언트 노출 제거 - 6B: 프론트엔드 폼 검증 통합 — Zod v4 + react-hook-form으로 Login 페이지 리팩토링, auth/wallet/trading Zod 스키마 생성 테스트: 백엔드 101개 + 프론트엔드 109개 = 총 210개 통과 Co-Authored-By: Claude Opus 4.6 --- .env.example | 6 + backend/jest.e2e.config.ts | 18 + backend/package.json | 13 +- backend/src/app.module.ts | 9 + backend/src/auth/auth.controller.ts | 5 + backend/src/auth/auth.service.spec.ts | 35 +- backend/src/auth/auth.service.ts | 28 +- backend/src/auth/dto/login.dto.ts | 5 +- backend/src/auth/dto/register.dto.ts | 18 +- .../src/blockchain/did-blockchain.service.ts | 8 +- backend/src/common/common.module.ts | 11 + backend/src/common/gateways/events.gateway.ts | 64 ++- backend/src/common/logger/logger.config.ts | 31 ++ backend/src/common/redis/redis.module.ts | 44 ++ backend/src/config/env.validation.ts | 67 +++ backend/src/health/health.controller.ts | 57 +- backend/src/main.ts | 26 +- backend/src/oracle/oracle.module.ts | 3 +- .../src/settlement/settlement.controller.ts | 35 ++ .../src/settlement/settlement.service.spec.ts | 160 +++++- backend/src/settlement/settlement.service.ts | 175 ++++++- backend/src/token/rec-token.controller.ts | 10 + backend/src/token/rec-token.service.spec.ts | 258 +++++++++ backend/src/token/rec-token.service.ts | 65 +++ backend/src/trading/trading.controller.ts | 32 ++ backend/src/trading/trading.service.spec.ts | 116 +++++ backend/src/trading/trading.service.ts | 146 ++++++ backend/src/users/dto/update-user.dto.ts | 20 + backend/src/users/users.controller.ts | 38 +- backend/src/users/users.service.spec.ts | 239 +++++++++ backend/src/users/users.service.ts | 107 +++- backend/test/auth.e2e-spec.ts | 134 +++++ backend/test/health.e2e-spec.ts | 62 +++ backend/test/trading.e2e-spec.ts | 119 +++++ frontend/package.json | 3 + frontend/src/App.tsx | 20 +- .../src/components/ErrorBoundary.test.tsx | 81 +++ .../src/components/RouteErrorBoundary.tsx | 63 +++ frontend/src/components/ui/Badge.test.tsx | 64 +++ frontend/src/components/ui/Button.test.tsx | 73 +++ frontend/src/components/ui/Card.test.tsx | 51 ++ frontend/src/components/ui/Modal.test.tsx | 65 +++ frontend/src/components/ui/StatCard.test.tsx | 62 +++ frontend/src/hooks/useWebSocket.ts | 30 +- frontend/src/lib/csv-export.ts | 46 ++ frontend/src/lib/format.test.ts | 54 ++ frontend/src/lib/format.ts | 29 ++ frontend/src/lib/schemas/auth.schema.ts | 27 + frontend/src/lib/schemas/trading.schema.ts | 28 + frontend/src/lib/schemas/wallet.schema.ts | 12 + frontend/src/pages/Admin.tsx | 488 +++++++++++++----- frontend/src/pages/Dashboard.test.tsx | 159 ++++++ frontend/src/pages/Dashboard.tsx | 97 ++-- frontend/src/pages/Login.test.tsx | 78 +++ frontend/src/pages/Login.tsx | 386 +++++++++----- frontend/src/pages/Metering.tsx | 14 +- frontend/src/pages/NotFound.test.tsx | 27 + frontend/src/pages/NotFound.tsx | 21 + frontend/src/pages/RECMarketplace.tsx | 74 ++- frontend/src/pages/Settlement.tsx | 37 +- frontend/src/pages/Trading.test.tsx | 85 +++ frontend/src/pages/Trading.tsx | 40 +- frontend/src/pages/Wallet.tsx | 2 +- frontend/src/services/analytics.service.ts | 23 + frontend/src/services/auth.service.test.ts | 128 +++++ frontend/src/services/rec-token.service.ts | 3 + frontend/src/services/trading.service.ts | 3 + frontend/src/store/authStore.test.ts | 157 ++++++ frontend/src/store/authStore.ts | 24 + frontend/src/test/setup.ts | 23 + frontend/src/test/test-utils.tsx | 28 + pnpm-lock.yaml | 476 ++++++++++++++++- shared/src/types/api-responses.types.ts | 52 ++ shared/src/types/index.ts | 2 + shared/src/types/trading.types.ts | 1 + shared/src/types/ws-events.types.ts | 101 ++++ 76 files changed, 4972 insertions(+), 429 deletions(-) create mode 100644 backend/jest.e2e.config.ts create mode 100644 backend/src/common/logger/logger.config.ts create mode 100644 backend/src/common/redis/redis.module.ts create mode 100644 backend/src/config/env.validation.ts create mode 100644 backend/src/token/rec-token.service.spec.ts create mode 100644 backend/src/users/dto/update-user.dto.ts create mode 100644 backend/src/users/users.service.spec.ts create mode 100644 backend/test/auth.e2e-spec.ts create mode 100644 backend/test/health.e2e-spec.ts create mode 100644 backend/test/trading.e2e-spec.ts create mode 100644 frontend/src/components/ErrorBoundary.test.tsx create mode 100644 frontend/src/components/RouteErrorBoundary.tsx create mode 100644 frontend/src/components/ui/Badge.test.tsx create mode 100644 frontend/src/components/ui/Button.test.tsx create mode 100644 frontend/src/components/ui/Card.test.tsx create mode 100644 frontend/src/components/ui/Modal.test.tsx create mode 100644 frontend/src/components/ui/StatCard.test.tsx create mode 100644 frontend/src/lib/csv-export.ts create mode 100644 frontend/src/lib/format.test.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/schemas/auth.schema.ts create mode 100644 frontend/src/lib/schemas/trading.schema.ts create mode 100644 frontend/src/lib/schemas/wallet.schema.ts create mode 100644 frontend/src/pages/Dashboard.test.tsx create mode 100644 frontend/src/pages/Login.test.tsx create mode 100644 frontend/src/pages/NotFound.test.tsx create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/Trading.test.tsx create mode 100644 frontend/src/services/auth.service.test.ts create mode 100644 frontend/src/store/authStore.test.ts create mode 100644 frontend/src/test/test-utils.tsx create mode 100644 shared/src/types/api-responses.types.ts create mode 100644 shared/src/types/ws-events.types.ts diff --git a/.env.example b/.env.example index 02150f9..56a44e0 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,9 @@ KPX_API_KEY= ORACLE_WEIGHT_EIA=0.40 ORACLE_WEIGHT_ENTSOE=0.35 ORACLE_WEIGHT_KPX=0.25 + +# Logging +LOG_LEVEL=debug + +# CORS +CORS_ORIGIN=http://localhost:5173 diff --git a/backend/jest.e2e.config.ts b/backend/jest.e2e.config.ts new file mode 100644 index 0000000..9f1ad6c --- /dev/null +++ b/backend/jest.e2e.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'jest'; + +const config: Config = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.e2e-spec.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + testEnvironment: 'node', + moduleNameMapper: { + '^@etp/shared(.*)$': '/../shared/src$1', + '^uuid$': '/src/__mocks__/uuid.ts', + }, + testTimeout: 30000, +}; + +export default config; diff --git a/backend/package.json b/backend/package.json index 792e710..3ea71e1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "prisma:studio": "prisma studio", "test": "jest", "test:watch": "jest --watch", - "test:cov": "jest --coverage" + "test:cov": "jest --coverage", + "test:e2e": "jest --config jest.e2e.config.ts" }, "dependencies": { "@etp/shared": "workspace:*", @@ -36,8 +37,14 @@ "bullmq": "^5.69.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "compression": "^1.8.1", + "helmet": "^8.1.0", + "ioredis": "^5.9.3", + "nestjs-pino": "^4.5.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pino": "^10.3.1", + "pino-http": "^11.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "socket.io": "^4.8.3", @@ -51,14 +58,18 @@ "@nestjs/schematics": "^10.2.0", "@nestjs/testing": "^11.1.13", "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.0.0", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^6.0.3", "@types/uuid": "^11.0.0", "jest": "^30.2.0", + "pino-pretty": "^13.1.3", "prisma": "^6.3.0", "source-map-support": "^0.5.21", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-loader": "^9.5.0", "ts-node": "^10.9.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1993327..6524705 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { ScheduleModule } from '@nestjs/schedule'; +import { LoggerModule } from 'nestjs-pino'; import { APP_GUARD } from '@nestjs/core'; import { PrismaModule } from './prisma/prisma.module'; import { CommonModule } from './common/common.module'; +import { RedisModule } from './common/redis/redis.module'; +import { getLoggerConfig } from './common/logger/logger.config'; import { BlockchainModule } from './blockchain/blockchain.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; @@ -15,14 +19,19 @@ import { AnalyticsModule } from './analytics/analytics.module'; import { OracleModule } from './oracle/oracle.module'; import { TokenModule } from './token/token.module'; import { HealthModule } from './health/health.module'; +import { validate } from './config/env.validation'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '../.env', + validate, }), + LoggerModule.forRoot(getLoggerConfig()), ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + ScheduleModule.forRoot(), + RedisModule, PrismaModule, CommonModule, BlockchainModule, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 1aedce6..5e5e9ce 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Controller, Post, Get, Body, Param, UseGuards, Req, Delete } from '@nestjs/common'; import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -11,12 +12,14 @@ export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') + @Throttle({ default: { ttl: 60000, limit: 5 } }) @ApiOperation({ summary: '회원가입 (DID 자동 발급)' }) register(@Body() dto: RegisterDto) { return this.authService.register(dto); } @Post('login') + @Throttle({ default: { ttl: 60000, limit: 5 } }) @ApiOperation({ summary: '이메일/비밀번호 로그인' }) login(@Body() dto: LoginDto) { return this.authService.login(dto); @@ -57,12 +60,14 @@ export class AuthController { // ========== DID 챌린지-응답 인증 ========== @Post('did/challenge') + @Throttle({ default: { ttl: 60000, limit: 3 } }) @ApiOperation({ summary: 'DID 로그인 챌린지 요청 (1단계)' }) createDIDChallenge(@Body('did') did: string) { return this.authService.createDIDChallenge(did); } @Post('did/login') + @Throttle({ default: { ttl: 60000, limit: 5 } }) @ApiOperation({ summary: 'DID 로그인 서명 제출 (2단계)' }) loginWithDID( @Body('did') did: string, diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 8ca13b3..0f152b9 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -6,6 +6,7 @@ import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { DIDBlockchainService } from '../blockchain/did-blockchain.service'; import { DIDSignatureService } from './services/did-signature.service'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; jest.mock('bcrypt'); @@ -49,6 +50,12 @@ describe('AuthService', () => { }), }; + const mockRedis = { + get: jest.fn(), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -57,6 +64,7 @@ describe('AuthService', () => { { provide: JwtService, useValue: mockJwtService }, { provide: DIDBlockchainService, useValue: mockDidService }, { provide: DIDSignatureService, useValue: mockSignatureService }, + { provide: REDIS_CLIENT, useValue: mockRedis }, ], }).compile(); @@ -255,7 +263,7 @@ describe('AuthService', () => { }); describe('createDIDChallenge', () => { - it('should create challenge for valid DID', async () => { + it('should create challenge and store in Redis', async () => { mockPrisma.dIDCredential.findUnique.mockResolvedValue({ did: 'did:etp:user-1', status: 'ACTIVE', @@ -265,6 +273,12 @@ describe('AuthService', () => { const result = await service.createDIDChallenge('did:etp:user-1'); expect(result.challenge).toBe('mock-challenge-hex'); expect(result.did).toBe('did:etp:user-1'); + expect(mockRedis.set).toHaveBeenCalledWith( + 'did:challenge:did:etp:user-1', + expect.any(String), + 'EX', + 300, + ); }); it('should reject invalid DID', async () => { @@ -278,15 +292,13 @@ describe('AuthService', () => { describe('loginWithDID', () => { it('should login with valid DID challenge-response', async () => { - // 먼저 챌린지 생성 - mockPrisma.dIDCredential.findUnique.mockResolvedValue({ - did: 'did:etp:user-1', - status: 'ACTIVE', - user: { id: 'user-1', name: '테스트' }, - }); - await service.createDIDChallenge('did:etp:user-1'); + // Redis에 챌린지가 저장되어 있다고 mock + const challengeData = { + challenge: 'mock-challenge-hex', + expiresAt: new Date(Date.now() + 300000).toISOString(), + }; + mockRedis.get.mockResolvedValue(JSON.stringify(challengeData)); - // DID 로그인 mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'test@example.com', @@ -298,9 +310,12 @@ describe('AuthService', () => { const result = await service.loginWithDID('did:etp:user-1', 'valid-sig'); expect(result.accessToken).toBe('mock-jwt-token'); expect(result.authMethod).toBe('DID'); + expect(mockRedis.del).toHaveBeenCalledWith('did:challenge:did:etp:user-1'); }); - it('should reject without challenge', async () => { + it('should reject without challenge in Redis', async () => { + mockRedis.get.mockResolvedValue(null); + await expect( service.loginWithDID('did:etp:unknown', 'sig'), ).rejects.toThrow(); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d26f90d..89feb1c 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,5 +1,6 @@ import { Injectable, + Inject, UnauthorizedException, ConflictException, BadRequestException, @@ -7,22 +8,24 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import Redis from 'ioredis'; import { PrismaService } from '../prisma/prisma.service'; import { DIDBlockchainService } from '../blockchain/did-blockchain.service'; import { DIDSignatureService } from './services/did-signature.service'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); - private readonly challengeStore = new Map(); constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly didService: DIDBlockchainService, private readonly didSignatureService: DIDSignatureService, + @Inject(REDIS_CLIENT) private readonly redis: Redis, ) {} async register(dto: RegisterDto) { @@ -174,6 +177,7 @@ export class AuthService { /** * DID 챌린지 생성 (DID 기반 로그인 1단계) + * Redis에 5분 TTL로 저장하여 서버 재시작/스케일아웃에도 안전 */ async createDIDChallenge(did: string) { const credential = await this.prisma.dIDCredential.findUnique({ @@ -186,10 +190,14 @@ export class AuthService { } const { challenge, expiresAt } = this.didSignatureService.generateChallenge(); - this.challengeStore.set(did, { challenge, expiresAt }); - // 5분 후 자동 삭제 - setTimeout(() => this.challengeStore.delete(did), 5 * 60 * 1000); + // Redis에 저장 (5분 TTL) + await this.redis.set( + `did:challenge:${did}`, + JSON.stringify({ challenge, expiresAt: expiresAt.toISOString() }), + 'EX', + 300, + ); return { challenge, expiresAt, did }; } @@ -198,13 +206,15 @@ export class AuthService { * DID 챌린지-응답 검증 (DID 기반 로그인 2단계) */ async loginWithDID(did: string, signature: string) { - const stored = this.challengeStore.get(did); - if (!stored) { + const raw = await this.redis.get(`did:challenge:${did}`); + if (!raw) { throw new BadRequestException('챌린지가 존재하지 않습니다. 먼저 챌린지를 요청하세요.'); } - if (new Date() > stored.expiresAt) { - this.challengeStore.delete(did); + const stored = JSON.parse(raw) as { challenge: string; expiresAt: string }; + + if (new Date() > new Date(stored.expiresAt)) { + await this.redis.del(`did:challenge:${did}`); throw new BadRequestException('챌린지가 만료되었습니다'); } @@ -218,7 +228,7 @@ export class AuthService { throw new UnauthorizedException(`DID 인증 실패: ${result.message}`); } - this.challengeStore.delete(did); + await this.redis.del(`did:challenge:${did}`); const user = await this.prisma.user.findUnique({ where: { id: result.userId }, diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts index 0ec8557..317a86d 100644 --- a/backend/src/auth/dto/login.dto.ts +++ b/backend/src/auth/dto/login.dto.ts @@ -1,12 +1,13 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class LoginDto { @ApiProperty({ example: 'user@example.com' }) - @IsEmail() + @IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요' }) email: string; @ApiProperty({ example: 'password123' }) @IsString() + @IsNotEmpty({ message: '비밀번호를 입력해주세요' }) password: string; } diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index f68617b..192853a 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -1,28 +1,32 @@ -import { IsEmail, IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsEnum, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; export class RegisterDto { @ApiProperty({ example: '홍길동' }) @IsString() - @IsNotEmpty() + @IsNotEmpty({ message: '이름을 입력해주세요' }) name: string; @ApiProperty({ example: 'user@example.com' }) - @IsEmail() + @IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요' }) email: string; - @ApiProperty({ example: 'password123' }) + @ApiProperty({ example: 'StrongP@ss1', description: '최소 8자, 대문자·소문자·숫자·특수문자 각 1개 이상' }) @IsString() - @MinLength(8) + @MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다' }) + @Matches(/(?=.*[a-z])/, { message: '비밀번호에 소문자가 최소 1개 포함되어야 합니다' }) + @Matches(/(?=.*[A-Z])/, { message: '비밀번호에 대문자가 최소 1개 포함되어야 합니다' }) + @Matches(/(?=.*\d)/, { message: '비밀번호에 숫자가 최소 1개 포함되어야 합니다' }) + @Matches(/(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/, { message: '비밀번호에 특수문자가 최소 1개 포함되어야 합니다' }) password: string; @ApiProperty({ enum: UserRole, example: UserRole.CONSUMER }) - @IsEnum(UserRole) + @IsEnum(UserRole, { message: '유효한 역할을 선택해주세요 (SUPPLIER, CONSUMER, ADMIN)' }) role: UserRole; @ApiProperty({ example: '한국전력공사' }) @IsString() - @IsNotEmpty() + @IsNotEmpty({ message: '조직명을 입력해주세요' }) organization: string; } diff --git a/backend/src/blockchain/did-blockchain.service.ts b/backend/src/blockchain/did-blockchain.service.ts index d8db0a0..9efe7e5 100644 --- a/backend/src/blockchain/did-blockchain.service.ts +++ b/backend/src/blockchain/did-blockchain.service.ts @@ -26,15 +26,12 @@ export class DIDBlockchainService { userId: string, role: string, organization: string, - ): Promise<{ did: string; publicKey: string; privateKey: string }> { + ): Promise<{ did: string; publicKey: string }> { // Ed25519 키 쌍 생성 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); const publicKeyHex = publicKey .export({ type: 'spki', format: 'der' }) .toString('hex'); - const privateKeyHex = privateKey - .export({ type: 'pkcs8', format: 'der' }) - .toString('hex'); // DID 식별자 생성 const did = `did:etp:${uuidv4()}`; @@ -52,7 +49,8 @@ export class DIDBlockchainService { this.logger.log(`DID 생성 완료: ${did} (user: ${userId})`); - return { did, publicKey: publicKeyHex, privateKey: privateKeyHex }; + // privateKey는 서버에서만 사용하며 클라이언트에 반환하지 않음 + return { did, publicKey: publicKeyHex }; } /** diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index c5c79c9..66d9f20 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -1,8 +1,19 @@ import { Global, Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventsGateway } from './gateways/events.gateway'; @Global() @Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'dev-secret'), + }), + inject: [ConfigService], + }), + ], providers: [EventsGateway], exports: [EventsGateway], }) diff --git a/backend/src/common/gateways/events.gateway.ts b/backend/src/common/gateways/events.gateway.ts index b8b1a00..d2e60c0 100644 --- a/backend/src/common/gateways/events.gateway.ts +++ b/backend/src/common/gateways/events.gateway.ts @@ -5,11 +5,23 @@ import { OnGatewayDisconnect, } from '@nestjs/websockets'; import { Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { Server, Socket } from 'socket.io'; +import type { + ITradeMatchedPayload, + IOrderUpdatedPayload, + IMeterReadingPayload, + ISettlementCompletedPayload, + IStatsUpdatePayload, + IPriceUpdatePayload, + IRECTokenUpdatePayload, +} from '@etp/shared'; @WebSocketGateway({ cors: { - origin: '*', + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, }, namespace: '/events', }) @@ -21,8 +33,36 @@ export class EventsGateway private readonly logger = new Logger(EventsGateway.name); - handleConnection(client: Socket) { - this.logger.log(`Client connected: ${client.id}`); + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async handleConnection(client: Socket) { + try { + const token = + client.handshake.auth?.token || + client.handshake.headers?.authorization?.replace('Bearer ', ''); + + if (!token) { + this.logger.warn(`인증 토큰 없는 연결 시도: ${client.id}`); + client.disconnect(); + return; + } + + const payload = this.jwtService.verify(token, { + secret: this.configService.get('JWT_SECRET', 'dev-secret'), + }); + + client.data.userId = payload.sub; + client.data.role = payload.role; + client.join(`user:${payload.sub}`); + + this.logger.log(`Client connected: ${client.id} (user: ${payload.sub})`); + } catch (error) { + this.logger.warn(`WebSocket 인증 실패: ${client.id} - ${(error as Error).message}`); + client.disconnect(); + } } handleDisconnect(client: Socket) { @@ -30,46 +70,46 @@ export class EventsGateway } /** 새 거래 체결 알림 */ - emitTradeMatched(trade: any) { + emitTradeMatched(trade: ITradeMatchedPayload) { this.server.emit('trade:matched', trade); } /** 주문 상태 변경 알림 */ - emitOrderUpdated(order: any) { + emitOrderUpdated(order: IOrderUpdatedPayload) { this.server.emit('order:updated', order); } /** 미터링 데이터 수신 알림 */ - emitMeterReading(reading: any) { + emitMeterReading(reading: IMeterReadingPayload) { this.server.emit('meter:reading', reading); } /** 정산 완료 알림 */ - emitSettlementCompleted(settlement: any) { + emitSettlementCompleted(settlement: ISettlementCompletedPayload) { this.server.emit('settlement:completed', settlement); } /** 거래 통계 업데이트 */ - emitStatsUpdate(stats: any) { + emitStatsUpdate(stats: IStatsUpdatePayload) { this.server.emit('stats:update', stats); } /** EPC 가격 업데이트 알림 */ - emitPriceUpdate(price: any) { + emitPriceUpdate(price: IPriceUpdatePayload) { this.server.emit('price:update', price); } - /** 토큰 잔액 변경 알림 */ + /** 토큰 잔액 변경 알림 (사용자별 전송) */ emitTokenBalanceUpdate(data: { userId: string; balance: number; lockedBalance: number; }) { - this.server.emit('token:balance', data); + this.server.to(`user:${data.userId}`).emit('token:balance', data); } /** REC 토큰 이벤트 */ - emitRECTokenUpdate(data: any) { + emitRECTokenUpdate(data: IRECTokenUpdatePayload) { this.server.emit('rec:update', data); } } diff --git a/backend/src/common/logger/logger.config.ts b/backend/src/common/logger/logger.config.ts new file mode 100644 index 0000000..406d31c --- /dev/null +++ b/backend/src/common/logger/logger.config.ts @@ -0,0 +1,31 @@ +import { Params } from 'nestjs-pino'; + +export function getLoggerConfig(): Params { + const isProduction = process.env.NODE_ENV === 'production'; + + return { + pinoHttp: { + level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'), + transport: isProduction + ? undefined + : { target: 'pino-pretty', options: { colorize: true, singleLine: true } }, + serializers: { + req: (req: any) => ({ + method: req.method, + url: req.url, + userId: req.raw?.user?.id || req.raw?.user?.sub || 'anonymous', + }), + res: (res: any) => ({ + statusCode: res.statusCode, + }), + }, + redact: { + paths: ['req.headers.authorization', 'req.headers.cookie'], + censor: '***', + }, + customProps: () => ({ + service: 'etp-backend', + }), + }, + }; +} diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts new file mode 100644 index 0000000..d099d62 --- /dev/null +++ b/backend/src/common/redis/redis.module.ts @@ -0,0 +1,44 @@ +import { Global, Module, OnModuleDestroy, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: (configService: ConfigService) => { + const logger = new Logger('RedisModule'); + + const client = new Redis({ + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + maxRetriesPerRequest: 3, + retryStrategy: (times) => Math.min(times * 200, 3000), + lazyConnect: true, + }); + + client.on('connect', () => logger.log('Redis 연결 성공')); + client.on('error', (err) => logger.error(`Redis 오류: ${err.message}`)); + client.on('close', () => logger.warn('Redis 연결 종료')); + + client.connect().catch((err) => { + logger.error(`Redis 초기 연결 실패: ${err.message}`); + }); + + return client; + }, + inject: [ConfigService], + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule implements OnModuleDestroy { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async onModuleDestroy() { + await this.redis.quit(); + } +} diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts new file mode 100644 index 0000000..3f60994 --- /dev/null +++ b/backend/src/config/env.validation.ts @@ -0,0 +1,67 @@ +import { plainToInstance } from 'class-transformer'; +import { IsNotEmpty, IsOptional, IsString, IsNumber, validateSync } from 'class-validator'; +import { Type } from 'class-transformer'; + +class EnvironmentVariables { + @IsString() + @IsNotEmpty({ message: 'DATABASE_URL 환경변수가 필요합니다' }) + DATABASE_URL: string; + + @IsString() + @IsNotEmpty({ message: 'JWT_SECRET 환경변수가 필요합니다' }) + JWT_SECRET: string; + + @IsString() + @IsOptional() + JWT_EXPIRES_IN?: string = '24h'; + + @IsString() + @IsOptional() + REDIS_HOST?: string = 'localhost'; + + @Type(() => Number) + @IsNumber() + @IsOptional() + REDIS_PORT?: number = 6379; + + @Type(() => Number) + @IsNumber() + @IsOptional() + BACKEND_PORT?: number = 3000; + + @IsString() + @IsOptional() + NODE_ENV?: string = 'development'; + + @IsString() + @IsOptional() + CORS_ORIGIN?: string = 'http://localhost:5173'; + + @IsString() + @IsOptional() + LOG_LEVEL?: string = 'debug'; + + @IsString() + @IsOptional() + FABRIC_ENABLED?: string = 'false'; +} + +export function validate(config: Record) { + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + + const errors = validateSync(validatedConfig, { + skipMissingProperties: false, + whitelist: false, + }); + + if (errors.length > 0) { + const messages = errors + .map((err) => Object.values(err.constraints || {}).join(', ')) + .join('\n'); + throw new Error(`환경변수 검증 실패:\n${messages}`); + } + + return validatedConfig; +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index c0b057c..567fd25 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,26 +1,36 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Inject, ServiceUnavailableException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { REDIS_CLIENT } from '../common/redis/redis.module'; +import Redis from 'ioredis'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; @ApiTags('Health') @Controller('health') export class HealthController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) private readonly redis: Redis, + ) {} @Get() - @ApiOperation({ summary: '서비스 상태 확인' }) + @ApiOperation({ summary: '서비스 상태 확인 (전체)' }) async check() { - const dbHealthy = await this.checkDatabase(); + const [dbHealthy, redisHealthy] = await Promise.all([ + this.checkDatabase(), + this.checkRedis(), + ]); const uptime = process.uptime(); const memoryUsage = process.memoryUsage(); + const isHealthy = dbHealthy && redisHealthy; return { - status: dbHealthy ? 'healthy' : 'unhealthy', + status: isHealthy ? 'healthy' : 'unhealthy', version: '0.1.0', uptime: Math.floor(uptime), timestamp: new Date().toISOString(), checks: { database: dbHealthy ? 'connected' : 'disconnected', + redis: redisHealthy ? 'connected' : 'disconnected', memory: { heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024) + 'MB', heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024) + 'MB', @@ -30,6 +40,34 @@ export class HealthController { }; } + @Get('live') + @ApiOperation({ summary: 'Liveness probe (프로세스 생존 확인)' }) + live() { + return { status: 'ok' }; + } + + @Get('ready') + @ApiOperation({ summary: 'Readiness probe (서비스 준비 상태)' }) + async ready() { + const [dbHealthy, redisHealthy] = await Promise.all([ + this.checkDatabase(), + this.checkRedis(), + ]); + const isReady = dbHealthy && redisHealthy; + + if (!isReady) { + throw new ServiceUnavailableException({ + status: 'not_ready', + checks: { + database: dbHealthy ? 'connected' : 'disconnected', + redis: redisHealthy ? 'connected' : 'disconnected', + }, + }); + } + + return { status: 'ready' }; + } + private async checkDatabase(): Promise { try { await this.prisma.$queryRaw`SELECT 1`; @@ -38,4 +76,13 @@ export class HealthController { return false; } } + + private async checkRedis(): Promise { + try { + const pong = await this.redis.ping(); + return pong === 'PONG'; + } catch { + return false; + } + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 9eccc2b..65c6604 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,22 +1,32 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { Logger } from 'nestjs-pino'; +import helmet from 'helmet'; +import compression from 'compression'; import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; -import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + + // Pino 구조화 로거 적용 + app.useLogger(app.get(Logger)); + + // 보안 헤더 + app.use(helmet()); + + // gzip 응답 압축 + app.use(compression()); + + // 정상 종료 (SIGTERM 시 Prisma disconnect 등 클린업) + app.enableShutdownHooks(); app.setGlobalPrefix('api'); // 글로벌 예외 필터 app.useGlobalFilters(new GlobalExceptionFilter()); - // 글로벌 로깅 인터셉터 - app.useGlobalInterceptors(new LoggingInterceptor()); - // 유효성 검증 파이프 app.useGlobalPipes( new ValidationPipe({ @@ -45,6 +55,8 @@ async function bootstrap() { const port = process.env.BACKEND_PORT || 3000; await app.listen(port); + + const logger = app.get(Logger); logger.log(`ETP Backend running on http://localhost:${port}`); logger.log(`Swagger docs: http://localhost:${port}/api/docs`); logger.log(`Health check: http://localhost:${port}/api/health`); diff --git a/backend/src/oracle/oracle.module.ts b/backend/src/oracle/oracle.module.ts index 6c582bd..6cf91d6 100644 --- a/backend/src/oracle/oracle.module.ts +++ b/backend/src/oracle/oracle.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; import { OracleService } from './oracle.service'; import { OracleController } from './oracle.controller'; import { EIAProvider } from './providers/eia.provider'; @@ -8,7 +7,7 @@ import { KPXProvider } from './providers/kpx.provider'; import { BlockchainModule } from '../blockchain/blockchain.module'; @Module({ - imports: [ScheduleModule.forRoot(), BlockchainModule], + imports: [BlockchainModule], controllers: [OracleController], providers: [OracleService, EIAProvider, ENTSOEProvider, KPXProvider], exports: [OracleService], diff --git a/backend/src/settlement/settlement.controller.ts b/backend/src/settlement/settlement.controller.ts index e5006e0..4a7abf9 100644 --- a/backend/src/settlement/settlement.controller.ts +++ b/backend/src/settlement/settlement.controller.ts @@ -3,6 +3,7 @@ import { Get, Post, Param, + Body, UseGuards, Req, } from '@nestjs/common'; @@ -10,6 +11,8 @@ import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger'; import { SettlementService } from './settlement.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { DIDAuthGuard } from '../auth/guards/did-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; @ApiTags('정산') @Controller('settlement') @@ -41,4 +44,36 @@ export class SettlementController { getStats(@Req() req: any) { return this.settlementService.getSettlementStats(req.user.id); } + + // ─── 분쟁(Dispute) 엔드포인트 ─── + + @Post('dispute/:tradeId') + @ApiOperation({ summary: '분쟁 제기' }) + createDispute( + @Param('tradeId') tradeId: string, + @Req() req: any, + @Body() body: { reason: string }, + ) { + return this.settlementService.createDispute(tradeId, req.user.id, body.reason); + } + + @Post('dispute/:tradeId/resolve') + @ApiOperation({ summary: '분쟁 해결 (Admin)' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + resolveDispute( + @Param('tradeId') tradeId: string, + @Req() req: any, + @Body() body: { resolution: 'REFUND' | 'COMPLETE' | 'CANCEL' }, + ) { + return this.settlementService.resolveDispute(tradeId, req.user.id, body.resolution); + } + + @Get('disputes') + @ApiOperation({ summary: '분쟁 목록 (Admin)' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + getDisputes() { + return this.settlementService.getDisputes(); + } } diff --git a/backend/src/settlement/settlement.service.spec.ts b/backend/src/settlement/settlement.service.spec.ts index 0991c56..4084c38 100644 --- a/backend/src/settlement/settlement.service.spec.ts +++ b/backend/src/settlement/settlement.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SettlementService } from './settlement.service'; import { PrismaService } from '../prisma/prisma.service'; import { EventsGateway } from '../common/gateways/events.gateway'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; describe('SettlementService', () => { let service: SettlementService; @@ -10,6 +10,7 @@ describe('SettlementService', () => { const mockPrisma = { trade: { findUnique: jest.fn(), + findMany: jest.fn(), update: jest.fn(), }, settlement: { @@ -168,4 +169,161 @@ describe('SettlementService', () => { expect(result.totalAmount).toBe(0); }); }); + + // ─── Phase 4 신규 테스트: 분쟁(Dispute) ─── + + describe('createDispute', () => { + const mockTrade = { + id: 'trade-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + status: 'MATCHED', + paymentCurrency: 'KRW', + settlement: null, + }; + + it('should create a dispute for a trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(mockTrade); + mockPrisma.$transaction.mockResolvedValue([{}]); + + const result = await service.createDispute('trade-1', 'buyer-1', '품질 문제'); + expect(result.tradeId).toBe('trade-1'); + expect(result.status).toBe('DISPUTED'); + expect(result.reason).toBe('품질 문제'); + expect(result.disputedBy).toBe('buyer-1'); + expect(mockGateway.emitSettlementCompleted).toHaveBeenCalledWith( + expect.objectContaining({ action: 'disputed', tradeId: 'trade-1' }), + ); + }); + + it('should freeze settlement when dispute is created', async () => { + const tradeWithSettlement = { + ...mockTrade, + settlement: { id: 'sett-1', status: 'PENDING' }, + }; + mockPrisma.trade.findUnique.mockResolvedValue(tradeWithSettlement); + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + await service.createDispute('trade-1', 'buyer-1', '배송 지연'); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for invalid trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(null); + + await expect( + service.createDispute('invalid', 'user-1', 'reason'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for non-party user', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(mockTrade); + + await expect( + service.createDispute('trade-1', 'other-user', 'reason'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for already disputed trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue({ + ...mockTrade, + status: 'DISPUTED', + }); + + await expect( + service.createDispute('trade-1', 'buyer-1', 'reason'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for cancelled trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue({ + ...mockTrade, + status: 'CANCELLED', + }); + + await expect( + service.createDispute('trade-1', 'buyer-1', 'reason'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('resolveDispute', () => { + const disputedTrade = { + id: 'trade-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + status: 'DISPUTED', + paymentCurrency: 'KRW', + settlement: { id: 'sett-1', status: 'PROCESSING', netAmount: 9800 }, + }; + + it('should resolve dispute with COMPLETE', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(disputedTrade); + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + const result = await service.resolveDispute('trade-1', 'admin-1', 'COMPLETE'); + expect(result.tradeId).toBe('trade-1'); + expect(result.resolution).toBe('COMPLETE'); + expect(result.tradeStatus).toBe('SETTLED'); + expect(result.settlementStatus).toBe('COMPLETED'); + expect(result.resolvedBy).toBe('admin-1'); + expect(mockGateway.emitSettlementCompleted).toHaveBeenCalledWith( + expect.objectContaining({ action: 'dispute-resolved', resolution: 'COMPLETE' }), + ); + }); + + it('should resolve dispute with CANCEL', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(disputedTrade); + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + const result = await service.resolveDispute('trade-1', 'admin-1', 'CANCEL'); + expect(result.tradeStatus).toBe('CANCELLED'); + expect(result.settlementStatus).toBe('FAILED'); + }); + + it('should resolve dispute with REFUND (KRW trade)', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(disputedTrade); + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + const result = await service.resolveDispute('trade-1', 'admin-1', 'REFUND'); + expect(result.tradeStatus).toBe('CANCELLED'); + expect(result.settlementStatus).toBe('FAILED'); + }); + + it('should throw NotFoundException for invalid trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue(null); + + await expect( + service.resolveDispute('invalid', 'admin-1', 'COMPLETE'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for non-disputed trade', async () => { + mockPrisma.trade.findUnique.mockResolvedValue({ + ...disputedTrade, + status: 'MATCHED', + }); + + await expect( + service.resolveDispute('trade-1', 'admin-1', 'COMPLETE'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getDisputes', () => { + it('should return disputed trades list', async () => { + const disputes = [ + { id: 'trade-1', status: 'DISPUTED', buyer: {}, seller: {} }, + ]; + mockPrisma.trade.findMany.mockResolvedValue(disputes); + + const result = await service.getDisputes(); + expect(result).toEqual(disputes); + expect(mockPrisma.trade.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: 'DISPUTED' }, + }), + ); + }); + }); }); diff --git a/backend/src/settlement/settlement.service.ts b/backend/src/settlement/settlement.service.ts index 21868ee..088fe89 100644 --- a/backend/src/settlement/settlement.service.ts +++ b/backend/src/settlement/settlement.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Inject, Optional, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, Inject, Optional, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { PaymentCurrency, SettlementStatus, TradeStatus } from '@prisma/client'; import { TokenService } from '../token/token.service'; @@ -173,4 +173,177 @@ export class SettlementService { totalNetAmount: stats._sum.netAmount || 0, }; } + + // ─── 분쟁(Dispute) 관련 ─── + + /** 분쟁 제기 — 거래 당사자가 호출 */ + async createDispute(tradeId: string, userId: string, reason: string) { + const trade = await this.prisma.trade.findUnique({ + where: { id: tradeId }, + include: { settlement: true }, + }); + if (!trade) { + throw new NotFoundException('거래를 찾을 수 없습니다'); + } + + // 거래 당사자인지 확인 + if (trade.buyerId !== userId && trade.sellerId !== userId) { + throw new BadRequestException('거래 당사자만 분쟁을 제기할 수 있습니다'); + } + + // 이미 분쟁 상태인지 확인 + if (trade.status === TradeStatus.DISPUTED) { + throw new BadRequestException('이미 분쟁 중인 거래입니다'); + } + + // CANCELLED이거나 이미 SETTLED가 아닌 상태 체크 + if (trade.status === TradeStatus.CANCELLED) { + throw new BadRequestException('취소된 거래는 분쟁을 제기할 수 없습니다'); + } + + const operations: any[] = [ + this.prisma.trade.update({ + where: { id: tradeId }, + data: { status: TradeStatus.DISPUTED }, + }), + ]; + + // 정산이 있으면 PROCESSING으로 변경 (동결) + if (trade.settlement) { + operations.push( + this.prisma.settlement.update({ + where: { id: trade.settlement.id }, + data: { status: SettlementStatus.PROCESSING }, + }), + ); + } + + await this.prisma.$transaction(operations); + + this.logger.warn(`분쟁 제기: 거래 ${tradeId}, 사유: ${reason}, 제기자: ${userId}`); + + this.eventsGateway.emitSettlementCompleted({ + action: 'disputed', + tradeId, + reason, + disputedBy: userId, + }); + + return { + tradeId, + status: TradeStatus.DISPUTED, + reason, + disputedBy: userId, + }; + } + + /** 분쟁 해결 — Admin만 호출 */ + async resolveDispute( + tradeId: string, + adminId: string, + resolution: 'REFUND' | 'COMPLETE' | 'CANCEL', + ) { + const trade = await this.prisma.trade.findUnique({ + where: { id: tradeId }, + include: { settlement: true }, + }); + if (!trade) { + throw new NotFoundException('거래를 찾을 수 없습니다'); + } + if (trade.status !== TradeStatus.DISPUTED) { + throw new BadRequestException('분쟁 상태의 거래만 해결할 수 있습니다'); + } + + let newTradeStatus: TradeStatus; + let newSettlementStatus: SettlementStatus | null = null; + + switch (resolution) { + case 'REFUND': + // EPC 거래인 경우 환불 처리 + if (trade.paymentCurrency === PaymentCurrency.EPC && this.tokenService && trade.settlement) { + try { + // seller → buyer 환불 + await this.tokenService.transfer( + trade.sellerId, + trade.buyerId, + trade.settlement.netAmount, + 'dispute-refund', + tradeId, + ); + } catch (error) { + this.logger.error(`환불 처리 실패 (거래 ${tradeId}): ${error.message}`); + } + } + newTradeStatus = TradeStatus.CANCELLED; + newSettlementStatus = SettlementStatus.FAILED; + break; + + case 'COMPLETE': + newTradeStatus = TradeStatus.SETTLED; + newSettlementStatus = SettlementStatus.COMPLETED; + break; + + case 'CANCEL': + newTradeStatus = TradeStatus.CANCELLED; + newSettlementStatus = SettlementStatus.FAILED; + break; + + default: + throw new BadRequestException('유효하지 않은 해결 방법입니다'); + } + + const operations: any[] = [ + this.prisma.trade.update({ + where: { id: tradeId }, + data: { status: newTradeStatus }, + }), + ]; + + if (trade.settlement && newSettlementStatus) { + operations.push( + this.prisma.settlement.update({ + where: { id: trade.settlement.id }, + data: { + status: newSettlementStatus, + ...(newSettlementStatus === SettlementStatus.COMPLETED && { settledAt: new Date() }), + }, + }), + ); + } + + await this.prisma.$transaction(operations); + + this.logger.log(`분쟁 해결: 거래 ${tradeId}, 결과: ${resolution}, 관리자: ${adminId}`); + + this.eventsGateway.emitSettlementCompleted({ + action: 'dispute-resolved', + tradeId, + resolution, + resolvedBy: adminId, + newTradeStatus, + }); + + return { + tradeId, + resolution, + tradeStatus: newTradeStatus, + settlementStatus: newSettlementStatus, + resolvedBy: adminId, + }; + } + + /** 분쟁 목록 조회 (Admin) */ + async getDisputes() { + return this.prisma.trade.findMany({ + where: { status: TradeStatus.DISPUTED }, + include: { + buyer: { select: { id: true, name: true, email: true, organization: true } }, + seller: { select: { id: true, name: true, email: true, organization: true } }, + settlement: true, + buyOrder: { select: { id: true, type: true, energySource: true, quantity: true, price: true } }, + sellOrder: { select: { id: true, type: true, energySource: true, quantity: true, price: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } } diff --git a/backend/src/token/rec-token.controller.ts b/backend/src/token/rec-token.controller.ts index 9691cad..9caa4c0 100644 --- a/backend/src/token/rec-token.controller.ts +++ b/backend/src/token/rec-token.controller.ts @@ -64,6 +64,16 @@ export class RECTokenController { return this.recTokenService.retire(id, req.user.id); } + @Post(':id/purchase') + @ApiOperation({ summary: 'REC 토큰 구매 (EPC 결제)' }) + async purchaseToken( + @Param('id') id: string, + @Request() req: any, + @Body() body: { epcAmount: number }, + ) { + return this.recTokenService.purchaseToken(id, req.user.id, body.epcAmount); + } + @Post('issue/:certId') @ApiOperation({ summary: 'REC 인증서에서 토큰 발행' }) async issueFromCert(@Param('certId') certId: string) { diff --git a/backend/src/token/rec-token.service.spec.ts b/backend/src/token/rec-token.service.spec.ts new file mode 100644 index 0000000..46b382d --- /dev/null +++ b/backend/src/token/rec-token.service.spec.ts @@ -0,0 +1,258 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RECTokenService } from './rec-token.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { EPCBlockchainService } from './epc-blockchain.service'; +import { EventsGateway } from '../common/gateways/events.gateway'; +import { TokenService } from './token.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +describe('RECTokenService', () => { + let service: RECTokenService; + + const mockPrisma = { + rECToken: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + rECCertificate: { + findUnique: jest.fn(), + }, + }; + + const mockBlockchain = { + issueRECToken: jest.fn().mockResolvedValue('tx-hash-123'), + transferRECToken: jest.fn().mockResolvedValue('tx-hash-456'), + retireRECToken: jest.fn().mockResolvedValue('tx-hash-789'), + }; + + const mockGateway = { + emitRECTokenUpdate: jest.fn(), + }; + + const mockTokenService = { + transfer: jest.fn().mockResolvedValue({}), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RECTokenService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: EPCBlockchainService, useValue: mockBlockchain }, + { provide: EventsGateway, useValue: mockGateway }, + { provide: TokenService, useValue: mockTokenService }, + ], + }).compile(); + + service = module.get(RECTokenService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getMarketplace', () => { + it('should return active tokens', async () => { + const tokens = [ + { id: 't1', status: 'ACTIVE', energySource: 'SOLAR', quantity: 100 }, + ]; + mockPrisma.rECToken.findMany.mockResolvedValue(tokens); + + const result = await service.getMarketplace(); + expect(result).toEqual(tokens); + expect(mockPrisma.rECToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'ACTIVE' }), + }), + ); + }); + + it('should filter by energySource', async () => { + mockPrisma.rECToken.findMany.mockResolvedValue([]); + + await service.getMarketplace({ energySource: 'WIND' as any }); + expect(mockPrisma.rECToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ energySource: 'WIND' }), + }), + ); + }); + }); + + describe('getToken', () => { + it('should return token by id', async () => { + const token = { id: 't1', status: 'ACTIVE' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + + const result = await service.getToken('t1'); + expect(result).toEqual(token); + }); + + it('should throw NotFoundException for invalid token', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(null); + + await expect(service.getToken('invalid')).rejects.toThrow(NotFoundException); + }); + }); + + describe('retire', () => { + it('should retire an ACTIVE token', async () => { + const token = { id: 't1', ownerId: 'user-1', status: 'ACTIVE' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + const retired = { ...token, status: 'RETIRED', retiredAt: new Date() }; + mockPrisma.rECToken.update.mockResolvedValue(retired); + + const result = await service.retire('t1', 'user-1'); + expect(result.status).toBe('RETIRED'); + expect(mockGateway.emitRECTokenUpdate).toHaveBeenCalledWith( + expect.objectContaining({ action: 'retired' }), + ); + }); + + it('should throw for non-owner', async () => { + const token = { id: 't1', ownerId: 'user-1', status: 'ACTIVE' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + + await expect(service.retire('t1', 'other-user')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw for already retired token', async () => { + const token = { id: 't1', ownerId: 'user-1', status: 'RETIRED' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + + await expect(service.retire('t1', 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ─── Phase 4 신규 테스트: 구매(Purchase) ─── + + describe('purchaseToken', () => { + const mockToken = { + id: 'token-1', + ownerId: 'seller-1', + status: 'ACTIVE', + energySource: 'SOLAR', + quantity: 100, + owner: { id: 'seller-1', name: 'Seller' }, + }; + + it('should purchase a token with EPC', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(mockToken); + const updated = { ...mockToken, ownerId: 'buyer-1' }; + mockPrisma.rECToken.update.mockResolvedValue(updated); + + const result = await service.purchaseToken('token-1', 'buyer-1', 50); + expect(result.ownerId).toBe('buyer-1'); + expect(result.epcPaid).toBe(50); + expect(result.previousOwnerId).toBe('seller-1'); + + // EPC transfer: buyer → seller + expect(mockTokenService.transfer).toHaveBeenCalledWith( + 'buyer-1', + 'seller-1', + 50, + 'rec-purchase', + 'token-1', + ); + + // Blockchain record + expect(mockBlockchain.transferRECToken).toHaveBeenCalledWith( + 'token-1', + 'seller-1', + 'buyer-1', + ); + + // WebSocket notification + expect(mockGateway.emitRECTokenUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'purchased', + buyerId: 'buyer-1', + previousOwnerId: 'seller-1', + epcAmount: 50, + }), + ); + }); + + it('should throw NotFoundException for invalid token', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(null); + + await expect( + service.purchaseToken('invalid', 'buyer-1', 50), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for inactive token', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue({ + ...mockToken, + status: 'RETIRED', + }); + + await expect( + service.purchaseToken('token-1', 'buyer-1', 50), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when buying own token', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(mockToken); + + await expect( + service.purchaseToken('token-1', 'seller-1', 50), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for zero/negative EPC amount', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(mockToken); + + await expect( + service.purchaseToken('token-1', 'buyer-1', 0), + ).rejects.toThrow(BadRequestException); + + await expect( + service.purchaseToken('token-1', 'buyer-1', -10), + ).rejects.toThrow(BadRequestException); + }); + + it('should propagate EPC transfer error', async () => { + mockPrisma.rECToken.findUnique.mockResolvedValue(mockToken); + mockTokenService.transfer.mockRejectedValueOnce( + new BadRequestException('잔액 부족'), + ); + + await expect( + service.purchaseToken('token-1', 'buyer-1', 9999), + ).rejects.toThrow(BadRequestException); + + // Token should NOT be updated if EPC transfer fails + expect(mockPrisma.rECToken.update).not.toHaveBeenCalled(); + }); + }); + + describe('transfer', () => { + it('should transfer token ownership', async () => { + const token = { id: 't1', ownerId: 'user-1', status: 'ACTIVE' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + const updated = { ...token, ownerId: 'user-2' }; + mockPrisma.rECToken.update.mockResolvedValue(updated); + + const result = await service.transfer('t1', 'user-1', 'user-2'); + expect(result.ownerId).toBe('user-2'); + expect(mockBlockchain.transferRECToken).toHaveBeenCalledWith('t1', 'user-1', 'user-2'); + }); + + it('should throw for non-owner transfer', async () => { + const token = { id: 't1', ownerId: 'user-1', status: 'ACTIVE' }; + mockPrisma.rECToken.findUnique.mockResolvedValue(token); + + await expect(service.transfer('t1', 'other-user', 'user-2')).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/backend/src/token/rec-token.service.ts b/backend/src/token/rec-token.service.ts index 9c5eb51..b040207 100644 --- a/backend/src/token/rec-token.service.ts +++ b/backend/src/token/rec-token.service.ts @@ -2,10 +2,13 @@ import { Injectable, NotFoundException, BadRequestException, + Inject, + Optional, Logger, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EPCBlockchainService } from './epc-blockchain.service'; +import { TokenService } from './token.service'; import { EventsGateway } from '../common/gateways/events.gateway'; import { RECTokenStatus, EnergySource } from '@prisma/client'; import { createHash } from 'crypto'; @@ -18,6 +21,7 @@ export class RECTokenService { private readonly prisma: PrismaService, private readonly epcBlockchain: EPCBlockchainService, private readonly eventsGateway: EventsGateway, + @Optional() @Inject(TokenService) private readonly tokenService?: TokenService, ) {} /** 기존 RECCertificate에서 NFT 토큰 발행 */ @@ -222,4 +226,65 @@ export class RECTokenService { orderBy: { issuedAt: 'desc' }, }); } + + /** REC 토큰 구매 (EPC 결제) */ + async purchaseToken(tokenId: string, buyerUserId: string, epcAmount: number) { + const token = await this.prisma.rECToken.findUnique({ + where: { id: tokenId }, + include: { owner: { select: { id: true, name: true } } }, + }); + if (!token) { + throw new NotFoundException('REC 토큰을 찾을 수 없습니다'); + } + if (token.status !== RECTokenStatus.ACTIVE) { + throw new BadRequestException('구매 가능한 상태가 아닙니다'); + } + if (token.ownerId === buyerUserId) { + throw new BadRequestException('자신의 토큰은 구매할 수 없습니다'); + } + if (epcAmount <= 0) { + throw new BadRequestException('EPC 금액은 0보다 커야 합니다'); + } + + // EPC 이체: buyer → owner + if (this.tokenService) { + await this.tokenService.transfer( + buyerUserId, + token.ownerId, + epcAmount, + 'rec-purchase', + tokenId, + ); + } + + // 소유권 이전 + const previousOwner = token.ownerId; + const updated = await this.prisma.rECToken.update({ + where: { id: tokenId }, + data: { ownerId: buyerUserId }, + }); + + // 블록체인 기록 + try { + await this.epcBlockchain.transferRECToken(tokenId, previousOwner, buyerUserId); + } catch (error) { + this.logger.error(`블록체인 REC 구매 기록 실패: ${error.message}`); + } + + this.logger.log(`REC 토큰 구매: ${tokenId}, 구매자: ${buyerUserId}, 판매자: ${previousOwner}, EPC: ${epcAmount}`); + + this.eventsGateway.emitRECTokenUpdate({ + action: 'purchased', + token: updated, + buyerId: buyerUserId, + previousOwnerId: previousOwner, + epcAmount, + }); + + return { + ...updated, + epcPaid: epcAmount, + previousOwnerId: previousOwner, + }; + } } diff --git a/backend/src/trading/trading.controller.ts b/backend/src/trading/trading.controller.ts index 0b1034c..6be06f7 100644 --- a/backend/src/trading/trading.controller.ts +++ b/backend/src/trading/trading.controller.ts @@ -14,6 +14,8 @@ import { TradingService } from './trading.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { DIDAuthGuard } from '../auth/guards/did-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { OrderType, OrderStatus } from '@prisma/client'; @ApiTags('전력거래') @@ -63,4 +65,34 @@ export class TradingController { getTradingStats() { return this.tradingService.getTradingStats(); } + + @Get('trades/recent') + @ApiOperation({ summary: '최근 체결 내역 (대시보드 피드)' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + getRecentTrades(@Query('limit') limit?: string) { + return this.tradingService.getRecentTrades(limit ? parseInt(limit, 10) : 10); + } + + // ─── Admin 엔드포인트 ─── + + @Get('admin/orders') + @ApiOperation({ summary: '전체 주문 조회 (Admin)' }) + @ApiQuery({ name: 'status', enum: OrderStatus, required: false }) + @ApiQuery({ name: 'type', enum: OrderType, required: false }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + getAdminOrders( + @Query('status') status?: OrderStatus, + @Query('type') type?: OrderType, + ) { + return this.tradingService.getAdminOrders({ status, type }); + } + + @Post('admin/orders/:id/cancel') + @ApiOperation({ summary: '관리자 주문 강제 취소' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + adminCancelOrder(@Param('id') id: string) { + return this.tradingService.adminCancelOrder(id); + } } diff --git a/backend/src/trading/trading.service.spec.ts b/backend/src/trading/trading.service.spec.ts index 75aa838..322257f 100644 --- a/backend/src/trading/trading.service.spec.ts +++ b/backend/src/trading/trading.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TradingService } from './trading.service'; import { PrismaService } from '../prisma/prisma.service'; import { EventsGateway } from '../common/gateways/events.gateway'; +import { NotFoundException } from '@nestjs/common'; describe('TradingService', () => { let service: TradingService; @@ -11,6 +12,7 @@ describe('TradingService', () => { create: jest.fn(), findMany: jest.fn(), findUnique: jest.fn(), + findFirst: jest.fn(), update: jest.fn(), }, trade: { @@ -85,4 +87,118 @@ describe('TradingService', () => { expect(result.totalVolume).toBe(5000); }); }); + + // ─── Phase 4 신규 테스트: Admin 기능 ─── + + describe('getAdminOrders', () => { + it('should return all orders without filter', async () => { + const orders = [ + { id: '1', type: 'BUY', status: 'PENDING', user: { id: 'u1', name: 'User1' } }, + { id: '2', type: 'SELL', status: 'FILLED', user: { id: 'u2', name: 'User2' } }, + ]; + mockPrisma.order.findMany.mockResolvedValue(orders); + + const result = await service.getAdminOrders(); + expect(result).toEqual(orders); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 200, + orderBy: { createdAt: 'desc' }, + }), + ); + }); + + it('should filter orders by status', async () => { + mockPrisma.order.findMany.mockResolvedValue([]); + + await service.getAdminOrders({ status: 'PENDING' as any }); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'PENDING' }), + }), + ); + }); + + it('should filter orders by type', async () => { + mockPrisma.order.findMany.mockResolvedValue([]); + + await service.getAdminOrders({ type: 'BUY' as any }); + expect(mockPrisma.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ type: 'BUY' }), + }), + ); + }); + }); + + describe('adminCancelOrder', () => { + it('should cancel a PENDING order', async () => { + const order = { + id: 'order-1', + type: 'BUY', + status: 'PENDING', + paymentCurrency: 'KRW', + remainingQty: 100, + price: 50, + userId: 'user-1', + }; + mockPrisma.order.findUnique.mockResolvedValue(order); + const cancelled = { ...order, status: 'CANCELLED' }; + mockPrisma.order.update.mockResolvedValue(cancelled); + + const result = await service.adminCancelOrder('order-1'); + expect(result.status).toBe('CANCELLED'); + expect(mockGateway.emitOrderUpdated).toHaveBeenCalledWith( + expect.objectContaining({ action: 'admin-cancelled' }), + ); + }); + + it('should cancel a PARTIALLY_FILLED order', async () => { + const order = { + id: 'order-2', + type: 'SELL', + status: 'PARTIALLY_FILLED', + paymentCurrency: 'KRW', + remainingQty: 50, + price: 100, + userId: 'user-2', + }; + mockPrisma.order.findUnique.mockResolvedValue(order); + const cancelled = { ...order, status: 'CANCELLED' }; + mockPrisma.order.update.mockResolvedValue(cancelled); + + const result = await service.adminCancelOrder('order-2'); + expect(result.status).toBe('CANCELLED'); + }); + + it('should throw NotFoundException for invalid order', async () => { + mockPrisma.order.findUnique.mockResolvedValue(null); + + await expect(service.adminCancelOrder('invalid')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw for already cancelled order', async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: 'order-1', + status: 'CANCELLED', + }); + + await expect(service.adminCancelOrder('order-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw for expired order', async () => { + mockPrisma.order.findUnique.mockResolvedValue({ + id: 'order-1', + status: 'EXPIRED', + }); + + await expect(service.adminCancelOrder('order-1')).rejects.toThrow( + NotFoundException, + ); + }); + }); }); diff --git a/backend/src/trading/trading.service.ts b/backend/src/trading/trading.service.ts index 4e1e469..3569cbc 100644 --- a/backend/src/trading/trading.service.ts +++ b/backend/src/trading/trading.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException, Inject, Optional, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { OrderStatus, OrderType, PaymentCurrency, TradeStatus } from '@prisma/client'; @@ -297,4 +298,149 @@ export class TradingService { }); } } + + /** + * 만료된 주문 자동 처리 (매분 실행) + * validUntil이 지난 PENDING/PARTIALLY_FILLED 주문을 EXPIRED로 변경하고 + * EPC 매수 주문의 잠금을 해제한다. + */ + @Cron('* * * * *') + async expireStaleOrders() { + const now = new Date(); + + const expiredOrders = await this.prisma.order.findMany({ + where: { + validUntil: { lt: now }, + status: { in: [OrderStatus.PENDING, OrderStatus.PARTIALLY_FILLED] }, + }, + }); + + if (expiredOrders.length === 0) return; + + this.logger.log(`만료 주문 처리 시작: ${expiredOrders.length}건`); + + for (const order of expiredOrders) { + try { + await this.prisma.order.update({ + where: { id: order.id }, + data: { status: OrderStatus.EXPIRED }, + }); + + // EPC 매수 주문: 잔여 잠금 해제 + if ( + order.paymentCurrency === PaymentCurrency.EPC && + order.type === OrderType.BUY && + order.remainingQty > 0 && + this.tokenService + ) { + const lockedAmount = order.remainingQty * order.price; + try { + await this.tokenService.unlockFromCancelledTrade( + order.userId, + lockedAmount, + order.id, + ); + } catch (unlockError) { + this.logger.error( + `만료 주문 EPC 잠금 해제 실패 (${order.id}): ${(unlockError as Error).message}`, + ); + } + } + + this.eventsGateway.emitOrderUpdated({ + action: 'expired', + order: { id: order.id, type: order.type, status: OrderStatus.EXPIRED }, + }); + } catch (error) { + this.logger.error(`주문 만료 처리 실패 (${order.id}): ${(error as Error).message}`); + } + } + + this.logger.log(`만료 주문 처리 완료: ${expiredOrders.length}건`); + } + + /** 최근 체결 내역 (대시보드 피드용) */ + async getRecentTrades(limit = 10) { + const trades = await this.prisma.trade.findMany({ + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + buyer: { select: { organization: true } }, + seller: { select: { organization: true } }, + }, + }); + + return trades.map((t) => ({ + id: t.id, + energySource: t.energySource, + quantity: t.quantity, + price: t.price, + totalAmount: t.totalAmount, + paymentCurrency: t.paymentCurrency, + buyerOrg: t.buyer.organization, + sellerOrg: t.seller.organization, + createdAt: t.createdAt.toISOString(), + })); + } + + // ─── Admin 기능 ─── + + /** 전체 주문 조회 (Admin) */ + async getAdminOrders(filters?: { + status?: OrderStatus; + type?: OrderType; + }) { + return this.prisma.order.findMany({ + where: { + ...(filters?.status && { status: filters.status }), + ...(filters?.type && { type: filters.type }), + }, + include: { + user: { select: { id: true, name: true, email: true, organization: true, role: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + } + + /** 관리자 주문 강제 취소 */ + async adminCancelOrder(orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + }); + if (!order) { + throw new NotFoundException('주문을 찾을 수 없습니다'); + } + if (order.status === OrderStatus.CANCELLED || order.status === OrderStatus.EXPIRED) { + throw new NotFoundException('이미 취소/만료된 주문입니다'); + } + + const updated = await this.prisma.order.update({ + where: { id: orderId }, + data: { status: OrderStatus.CANCELLED }, + }); + + // EPC 매수 주문: 잔여 잠금 해제 + if ( + order.paymentCurrency === PaymentCurrency.EPC && + order.type === OrderType.BUY && + order.remainingQty > 0 && + this.tokenService + ) { + const lockedAmount = order.remainingQty * order.price; + try { + await this.tokenService.unlockFromCancelledTrade(order.userId, lockedAmount, orderId); + } catch (error) { + this.logger.error(`관리자 취소 EPC 잠금 해제 실패 (${orderId}): ${(error as Error).message}`); + } + } + + this.eventsGateway.emitOrderUpdated({ + action: 'admin-cancelled', + order: { id: updated.id, type: updated.type, status: updated.status }, + }); + + this.logger.warn(`관리자 주문 강제 취소: ${orderId}`); + return updated; + } } diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..75e4634 --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { UserStatus } from '@prisma/client'; + +export class UpdateUserDto { + @ApiPropertyOptional({ description: '이름' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: '조직명' }) + @IsOptional() + @IsString() + organization?: string; + + @ApiPropertyOptional({ description: '상태', enum: UserStatus }) + @IsOptional() + @IsEnum(UserStatus) + status?: UserStatus; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index d7c6c0e..0f1404b 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,7 +1,19 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Post, + Param, + Query, + Body, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { UsersService } from './users.service'; +import { UpdateUserDto } from './dto/update-user.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '@prisma/client'; @ApiTags('사용자') @@ -18,6 +30,14 @@ export class UsersController { return this.usersService.findAll(role); } + @Get('admin/all') + @ApiOperation({ summary: '전체 사용자 + 통계 (Admin)' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + getAllUsersWithStats() { + return this.usersService.getAllUsersWithStats(); + } + @Get(':id') @ApiOperation({ summary: '사용자 상세 조회' }) findOne(@Param('id') id: string) { @@ -29,4 +49,20 @@ export class UsersController { getDashboardStats(@Param('id') id: string) { return this.usersService.getDashboardStats(id); } + + @Patch(':id') + @ApiOperation({ summary: '사용자 정보 수정 (Admin)' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) { + return this.usersService.updateUser(id, dto); + } + + @Post(':id/deactivate') + @ApiOperation({ summary: '사용자 비활성화 (Admin)' }) + @UseGuards(RolesGuard) + @Roles('ADMIN') + deactivateUser(@Param('id') id: string) { + return this.usersService.deactivateUser(id); + } } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts new file mode 100644 index 0000000..bba6acf --- /dev/null +++ b/backend/src/users/users.service.spec.ts @@ -0,0 +1,239 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotFoundException } from '@nestjs/common'; + +describe('UsersService', () => { + let service: UsersService; + + const mockPrisma = { + user: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + order: { + count: jest.fn(), + }, + trade: { + count: jest.fn(), + }, + dIDCredential: { + update: jest.fn(), + }, + $transaction: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(UsersService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return all users', async () => { + const users = [ + { id: '1', email: 'a@test.com', name: 'User A', role: 'SUPPLIER' }, + ]; + mockPrisma.user.findMany.mockResolvedValue(users); + + const result = await service.findAll(); + expect(result).toEqual(users); + }); + + it('should filter by role', async () => { + mockPrisma.user.findMany.mockResolvedValue([]); + + await service.findAll('ADMIN' as any); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { role: 'ADMIN' }, + }), + ); + }); + }); + + describe('findOne', () => { + it('should return user by id', async () => { + const user = { id: '1', email: 'a@test.com', name: 'User A' }; + mockPrisma.user.findUnique.mockResolvedValue(user); + + const result = await service.findOne('1'); + expect(result).toEqual(user); + }); + + it('should throw NotFoundException for invalid id', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(service.findOne('invalid')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getDashboardStats', () => { + it('should return order and trade counts', async () => { + mockPrisma.order.count.mockResolvedValue(5); + mockPrisma.trade.count.mockResolvedValue(3); + + const result = await service.getDashboardStats('user-1'); + expect(result.orderCount).toBe(5); + expect(result.tradeCount).toBe(3); + }); + }); + + // ─── Phase 4 신규 테스트 ─── + + describe('updateUser', () => { + it('should update user name', async () => { + const user = { id: 'user-1', name: 'Old Name', organization: 'Org' }; + mockPrisma.user.findUnique.mockResolvedValue(user); + const updated = { ...user, name: 'New Name' }; + mockPrisma.user.update.mockResolvedValue(updated); + + const result = await service.updateUser('user-1', { name: 'New Name' }); + expect(result.name).toBe('New Name'); + expect(mockPrisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'user-1' }, + data: expect.objectContaining({ name: 'New Name' }), + }), + ); + }); + + it('should update user organization', async () => { + const user = { id: 'user-1', name: 'User', organization: 'Old Org' }; + mockPrisma.user.findUnique.mockResolvedValue(user); + const updated = { ...user, organization: 'New Org' }; + mockPrisma.user.update.mockResolvedValue(updated); + + const result = await service.updateUser('user-1', { organization: 'New Org' }); + expect(result.organization).toBe('New Org'); + }); + + it('should update user status', async () => { + const user = { id: 'user-1', name: 'User', status: 'ACTIVE' }; + mockPrisma.user.findUnique.mockResolvedValue(user); + const updated = { ...user, status: 'SUSPENDED' }; + mockPrisma.user.update.mockResolvedValue(updated); + + const result = await service.updateUser('user-1', { status: 'SUSPENDED' as any }); + expect(result.status).toBe('SUSPENDED'); + }); + + it('should throw NotFoundException for invalid user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.updateUser('invalid', { name: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deactivateUser', () => { + it('should deactivate user and revoke DID', async () => { + const user = { + id: 'user-1', + email: 'user@test.com', + status: 'ACTIVE', + didCredential: { id: 'did-1', did: 'did:etp:user-1', status: 'ACTIVE' }, + }; + mockPrisma.user.findUnique.mockResolvedValue(user); + mockPrisma.$transaction.mockResolvedValue([{}, {}]); + + const result = await service.deactivateUser('user-1'); + expect(result.id).toBe('user-1'); + expect(result.status).toBe('SUSPENDED'); + expect(result.didRevoked).toBe(true); + }); + + it('should deactivate user without DID', async () => { + const user = { + id: 'user-2', + email: 'user2@test.com', + status: 'ACTIVE', + didCredential: null, + }; + mockPrisma.user.findUnique.mockResolvedValue(user); + mockPrisma.$transaction.mockResolvedValue([{}]); + + const result = await service.deactivateUser('user-2'); + expect(result.id).toBe('user-2'); + expect(result.status).toBe('SUSPENDED'); + expect(result.didRevoked).toBe(false); + }); + + it('should not revoke already revoked DID', async () => { + const user = { + id: 'user-3', + email: 'user3@test.com', + status: 'ACTIVE', + didCredential: { id: 'did-3', did: 'did:etp:user-3', status: 'REVOKED' }, + }; + mockPrisma.user.findUnique.mockResolvedValue(user); + mockPrisma.$transaction.mockResolvedValue([{}]); + + const result = await service.deactivateUser('user-3'); + expect(result.didRevoked).toBe(true); + // $transaction should only have 1 operation (user update only, no DID update) + const transactionCall = mockPrisma.$transaction.mock.calls[0][0]; + expect(transactionCall).toHaveLength(1); + }); + + it('should throw NotFoundException for invalid user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(service.deactivateUser('invalid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getAllUsersWithStats', () => { + it('should return users with stats', async () => { + const users = [ + { + id: 'u1', + email: 'a@test.com', + name: 'User A', + role: 'SUPPLIER', + organization: 'Org A', + status: 'ACTIVE', + createdAt: new Date(), + didCredential: { did: 'did:etp:u1', status: 'ACTIVE' }, + _count: { orders: 5, buyTrades: 2, sellTrades: 3 }, + }, + { + id: 'u2', + email: 'b@test.com', + name: 'User B', + role: 'CONSUMER', + organization: 'Org B', + status: 'ACTIVE', + createdAt: new Date(), + didCredential: null, + _count: { orders: 0, buyTrades: 0, sellTrades: 0 }, + }, + ]; + mockPrisma.user.findMany.mockResolvedValue(users); + + const result = await service.getAllUsersWithStats(); + expect(result).toHaveLength(2); + expect(result[0].did).toBe('did:etp:u1'); + expect(result[0].didStatus).toBe('ACTIVE'); + expect(result[0].orderCount).toBe(5); + expect(result[0].tradeCount).toBe(5); // buyTrades + sellTrades + expect(result[1].did).toBeNull(); + expect(result[1].didStatus).toBeNull(); + expect(result[1].tradeCount).toBe(0); + }); + }); +}); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5940e6b..ae17433 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,9 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { UserRole } from '@prisma/client'; +import { UserRole, UserStatus } from '@prisma/client'; +import { UpdateUserDto } from './dto/update-user.dto'; @Injectable() export class UsersService { + private readonly logger = new Logger(UsersService.name); + constructor(private readonly prisma: PrismaService) {} async findAll(role?: UserRole) { @@ -50,4 +53,104 @@ export class UsersService { ]); return { orderCount, tradeCount }; } + + /** 사용자 정보 수정 (Admin) */ + async updateUser(id: string, dto: UpdateUserDto) { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } + + const updated = await this.prisma.user.update({ + where: { id }, + data: { + ...(dto.name && { name: dto.name }), + ...(dto.organization && { organization: dto.organization }), + ...(dto.status && { status: dto.status }), + }, + select: { + id: true, + email: true, + name: true, + role: true, + organization: true, + status: true, + createdAt: true, + }, + }); + + this.logger.log(`사용자 정보 수정: ${id} → ${JSON.stringify(dto)}`); + return updated; + } + + /** 사용자 비활성화 (Admin) — DID도 REVOKED 처리 */ + async deactivateUser(id: string) { + const user = await this.prisma.user.findUnique({ + where: { id }, + include: { didCredential: true }, + }); + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다'); + } + + const operations: any[] = [ + this.prisma.user.update({ + where: { id }, + data: { status: UserStatus.SUSPENDED }, + }), + ]; + + // DID가 있으면 REVOKED 처리 + if (user.didCredential && user.didCredential.status === 'ACTIVE') { + operations.push( + this.prisma.dIDCredential.update({ + where: { userId: id }, + data: { status: 'REVOKED' }, + }), + ); + } + + await this.prisma.$transaction(operations); + this.logger.warn(`사용자 비활성화: ${id} (${user.email})`); + + return { id, status: UserStatus.SUSPENDED, didRevoked: !!user.didCredential }; + } + + /** 전체 사용자 목록 + 통계 (Admin) */ + async getAllUsersWithStats() { + const users = await this.prisma.user.findMany({ + select: { + id: true, + email: true, + name: true, + role: true, + organization: true, + status: true, + createdAt: true, + didCredential: { select: { did: true, status: true } }, + _count: { + select: { + orders: true, + buyTrades: true, + sellTrades: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + organization: u.organization, + status: u.status, + createdAt: u.createdAt, + did: u.didCredential?.did || null, + didStatus: u.didCredential?.status || null, + orderCount: u._count.orders, + tradeCount: u._count.buyTrades + u._count.sellTrades, + })); + } } diff --git a/backend/test/auth.e2e-spec.ts b/backend/test/auth.e2e-spec.ts new file mode 100644 index 0000000..c111cbf --- /dev/null +++ b/backend/test/auth.e2e-spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('Auth (e2e)', () => { + let app: INestApplication; + let accessToken: string; + const testEmail = `e2e-auth-${Date.now()}@test.com`; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /api/auth/register', () => { + it('should register a new user', () => { + return request(app.getHttpServer()) + .post('/api/auth/register') + .send({ + email: testEmail, + password: 'testpass123', + name: 'E2E 테스트', + role: 'SUPPLIER', + organization: 'E2E기업', + }) + .expect(201) + .expect((res) => { + expect(res.body.accessToken).toBeDefined(); + expect(res.body.user.email).toBe(testEmail); + accessToken = res.body.accessToken; + }); + }); + + it('should reject duplicate email', () => { + return request(app.getHttpServer()) + .post('/api/auth/register') + .send({ + email: testEmail, + password: 'testpass123', + name: 'E2E 중복', + role: 'CONSUMER', + organization: 'E2E기업', + }) + .expect(409); + }); + + it('should reject invalid payload', () => { + return request(app.getHttpServer()) + .post('/api/auth/register') + .send({ email: 'not-valid' }) + .expect(400); + }); + }); + + describe('POST /api/auth/login', () => { + it('should login with valid credentials', () => { + return request(app.getHttpServer()) + .post('/api/auth/login') + .send({ + email: testEmail, + password: 'testpass123', + }) + .expect(201) + .expect((res) => { + expect(res.body.accessToken).toBeDefined(); + expect(res.body.user.email).toBe(testEmail); + }); + }); + + it('should reject invalid credentials', () => { + return request(app.getHttpServer()) + .post('/api/auth/login') + .send({ + email: testEmail, + password: 'wrongpassword', + }) + .expect(401); + }); + + it('should reject non-existent user', () => { + return request(app.getHttpServer()) + .post('/api/auth/login') + .send({ + email: 'nonexistent@test.com', + password: 'testpass123', + }) + .expect(401); + }); + }); + + describe('GET /api/auth/profile', () => { + it('should return profile with valid token', () => { + return request(app.getHttpServer()) + .get('/api/auth/profile') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe(testEmail); + expect(res.body).not.toHaveProperty('password'); + }); + }); + + it('should reject without token', () => { + return request(app.getHttpServer()) + .get('/api/auth/profile') + .expect(401); + }); + + it('should reject with invalid token', () => { + return request(app.getHttpServer()) + .get('/api/auth/profile') + .set('Authorization', 'Bearer invalid-token') + .expect(401); + }); + }); +}); diff --git a/backend/test/health.e2e-spec.ts b/backend/test/health.e2e-spec.ts new file mode 100644 index 0000000..7073844 --- /dev/null +++ b/backend/test/health.e2e-spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('Health (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /api/health', () => { + it('should return health status', () => { + return request(app.getHttpServer()) + .get('/api/health') + .expect(200) + .expect((res) => { + expect(res.body.status).toBeDefined(); + expect(res.body.version).toBe('0.1.0'); + expect(res.body.uptime).toBeDefined(); + expect(res.body.timestamp).toBeDefined(); + expect(res.body.checks).toBeDefined(); + expect(res.body.checks.database).toBeDefined(); + expect(res.body.checks.redis).toBeDefined(); + expect(res.body.checks.memory).toBeDefined(); + }); + }); + }); + + describe('GET /api/health/live', () => { + it('should return liveness status', () => { + return request(app.getHttpServer()) + .get('/api/health/live') + .expect(200) + .expect((res) => { + expect(res.body.status).toBe('ok'); + }); + }); + }); + + describe('GET /api/health/ready', () => { + it('should return readiness status when services are up', () => { + return request(app.getHttpServer()) + .get('/api/health/ready') + .expect(200) + .expect((res) => { + expect(res.body.status).toBe('ready'); + }); + }); + }); +}); diff --git a/backend/test/trading.e2e-spec.ts b/backend/test/trading.e2e-spec.ts new file mode 100644 index 0000000..0d6cc73 --- /dev/null +++ b/backend/test/trading.e2e-spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('Trading (e2e)', () => { + let app: INestApplication; + let supplierToken: string; + let consumerToken: string; + const supplierEmail = `e2e-supplier-${Date.now()}@test.com`; + const consumerEmail = `e2e-consumer-${Date.now()}@test.com`; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + await app.init(); + + // 공급자 등록 + const supplierRes = await request(app.getHttpServer()) + .post('/api/auth/register') + .send({ + email: supplierEmail, + password: 'testpass123', + name: 'E2E 공급자', + role: 'SUPPLIER', + organization: 'E2E공급기업', + }); + supplierToken = supplierRes.body.accessToken; + + // 소비자 등록 + const consumerRes = await request(app.getHttpServer()) + .post('/api/auth/register') + .send({ + email: consumerEmail, + password: 'testpass123', + name: 'E2E 소비자', + role: 'CONSUMER', + organization: 'E2E소비기업', + }); + consumerToken = consumerRes.body.accessToken; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /api/trading/orders', () => { + it('should create a sell order', () => { + return request(app.getHttpServer()) + .post('/api/trading/orders') + .set('Authorization', `Bearer ${supplierToken}`) + .send({ + type: 'SELL', + energySource: 'SOLAR', + quantity: 100, + price: 50, + validFrom: new Date().toISOString(), + validUntil: new Date(Date.now() + 86400000).toISOString(), + }) + .expect(201) + .expect((res) => { + expect(res.body.type).toBe('SELL'); + expect(res.body.status).toBe('PENDING'); + expect(res.body.quantity).toBe(100); + }); + }); + + it('should reject without authentication', () => { + return request(app.getHttpServer()) + .post('/api/trading/orders') + .send({ + type: 'SELL', + energySource: 'SOLAR', + quantity: 100, + price: 50, + validFrom: new Date().toISOString(), + validUntil: new Date(Date.now() + 86400000).toISOString(), + }) + .expect(401); + }); + }); + + describe('GET /api/trading/orders', () => { + it('should list orders', () => { + return request(app.getHttpServer()) + .get('/api/trading/orders') + .set('Authorization', `Bearer ${supplierToken}`) + .expect(200) + .expect((res) => { + expect(Array.isArray(res.body)).toBe(true); + }); + }); + }); + + describe('GET /api/trading/stats', () => { + it('should return trading stats', () => { + return request(app.getHttpServer()) + .get('/api/trading/stats') + .set('Authorization', `Bearer ${supplierToken}`) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('totalVolume'); + expect(res.body).toHaveProperty('totalTrades'); + }); + }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 8151009..d8b8231 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,15 @@ }, "dependencies": { "@etp/shared": "workspace:*", + "@hookform/resolvers": "^5.2.2", "axios": "^1.7.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-hook-form": "^7.71.1", "react-router-dom": "^7.1.0", "recharts": "^2.15.0", "socket.io-client": "^4.8.3", + "zod": "^4.3.6", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1740b3..d830c60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { useAuthStore } from './store/authStore'; import { ToastProvider } from './components/ui/Toast'; import Layout from './components/Layout'; import ErrorBoundary from './components/ErrorBoundary'; +import RouteErrorBoundary from './components/RouteErrorBoundary'; import Login from './pages/Login'; const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -14,6 +15,7 @@ const Wallet = lazy(() => import('./pages/Wallet')); const PriceOracle = lazy(() => import('./pages/PriceOracle')); const RECMarketplace = lazy(() => import('./pages/RECMarketplace')); const Admin = lazy(() => import('./pages/Admin')); +const NotFound = lazy(() => import('./pages/NotFound')); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -45,15 +47,17 @@ function App() { } > - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + } /> diff --git a/frontend/src/components/ErrorBoundary.test.tsx b/frontend/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..abda4e8 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { renderWithProviders, screen, fireEvent } from '../test/test-utils'; +import ErrorBoundary from './ErrorBoundary'; + +function ThrowError({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) throw new Error('Test error message'); + return
Normal content
; +} + +describe('ErrorBoundary', () => { + // Suppress console.error during tests since we intentionally throw errors + const originalConsoleError = console.error; + beforeAll(() => { + console.error = vi.fn(); + }); + afterAll(() => { + console.error = originalConsoleError; + }); + + it('renders children when no error', () => { + renderWithProviders( + +
Child content
+
+ ); + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + + it('renders error message when child throws', () => { + renderWithProviders( + + + + ); + expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('renders retry button when error occurs', () => { + renderWithProviders( + + + + ); + expect(screen.getByText('다시 시도')).toBeInTheDocument(); + }); + + it('resets error state on retry click', () => { + let shouldThrow = true; + function ConditionalThrow() { + if (shouldThrow) throw new Error('Test error message'); + return
Normal content
; + } + + renderWithProviders( + + + + ); + + // Should show error UI + expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument(); + + // Stop throwing before retry + shouldThrow = false; + + // Click retry — ErrorBoundary resets state and re-renders children + fireEvent.click(screen.getByText('다시 시도')); + + expect(screen.getByText('Normal content')).toBeInTheDocument(); + }); + + it('renders custom fallback when provided', () => { + renderWithProviders( + Custom Fallback}> + + + ); + expect(screen.getByText('Custom Fallback')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/RouteErrorBoundary.tsx b/frontend/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..9153263 --- /dev/null +++ b/frontend/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,63 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class RouteErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('RouteErrorBoundary caught:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+

⚠️

+

+ 페이지 로드 중 오류 발생 +

+

+ {this.state.error?.message || '알 수 없는 오류가 발생했습니다'} +

+
+ + this.setState({ hasError: false, error: null })} + > + 대시보드로 이동 + +
+
+
+ ); + } + + return this.props.children; + } +} + +export default RouteErrorBoundary; diff --git a/frontend/src/components/ui/Badge.test.tsx b/frontend/src/components/ui/Badge.test.tsx new file mode 100644 index 0000000..79f5fb9 --- /dev/null +++ b/frontend/src/components/ui/Badge.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { renderWithProviders, screen } from '../../test/test-utils'; +import Badge from './Badge'; + +describe('Badge', () => { + it('renders children text', () => { + renderWithProviders(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('renders neutral variant by default', () => { + renderWithProviders(Default); + const badge = screen.getByText('Default'); + expect(badge.className).toContain('bg-gray-50'); + }); + + it('renders success variant', () => { + renderWithProviders(Success); + const badge = screen.getByText('Success'); + expect(badge.className).toContain('bg-emerald-50'); + }); + + it('renders error variant', () => { + renderWithProviders(Error); + const badge = screen.getByText('Error'); + expect(badge.className).toContain('bg-red-50'); + }); + + it('renders warning variant', () => { + renderWithProviders(Warning); + const badge = screen.getByText('Warning'); + expect(badge.className).toContain('bg-amber-50'); + }); + + it('renders info variant', () => { + renderWithProviders(Info); + const badge = screen.getByText('Info'); + expect(badge.className).toContain('bg-blue-50'); + }); + + it('renders dot indicator when dot prop is true', () => { + const { container } = renderWithProviders(Active); + const dot = container.querySelector('.rounded-full.bg-emerald-500'); + expect(dot).toBeInTheDocument(); + }); + + it('does not render dot indicator when dot prop is false', () => { + const { container } = renderWithProviders(Active); + const dot = container.querySelector('.w-1\\.5'); + expect(dot).not.toBeInTheDocument(); + }); + + it('renders sm size by default', () => { + renderWithProviders(Small); + const badge = screen.getByText('Small'); + expect(badge.className).toContain('text-xs'); + }); + + it('renders md size', () => { + renderWithProviders(Medium); + const badge = screen.getByText('Medium'); + expect(badge.className).toContain('text-sm'); + }); +}); diff --git a/frontend/src/components/ui/Button.test.tsx b/frontend/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..4651685 --- /dev/null +++ b/frontend/src/components/ui/Button.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderWithProviders, screen, fireEvent } from '../../test/test-utils'; +import Button from './Button'; + +describe('Button', () => { + it('renders children text', () => { + renderWithProviders(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('renders as primary variant by default', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg-primary-600'); + }); + + it('renders secondary variant', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg-gray-100'); + }); + + it('renders danger variant', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg-red-600'); + }); + + it('renders ghost variant', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('hover:bg-gray-100'); + }); + + it('renders outline variant', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('border-primary-500'); + }); + + it('handles click events', () => { + const onClick = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('is disabled when loading', () => { + renderWithProviders(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('shows spinner when loading', () => { + renderWithProviders(); + expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument(); + }); + + it('is disabled when disabled prop is set', () => { + renderWithProviders(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies size classes', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('px-6'); + }); + + it('renders icon when provided', () => { + renderWithProviders(); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/Card.test.tsx b/frontend/src/components/ui/Card.test.tsx new file mode 100644 index 0000000..771bac1 --- /dev/null +++ b/frontend/src/components/ui/Card.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { renderWithProviders, screen } from '../../test/test-utils'; +import Card from './Card'; + +describe('Card', () => { + it('renders children', () => { + renderWithProviders(Card Content); + expect(screen.getByText('Card Content')).toBeInTheDocument(); + }); + + it('renders title', () => { + renderWithProviders(Content); + expect(screen.getByText('Card Title')).toBeInTheDocument(); + }); + + it('renders subtitle', () => { + renderWithProviders(Content); + expect(screen.getByText('Subtitle')).toBeInTheDocument(); + }); + + it('renders action slot', () => { + renderWithProviders( + Action}>Content + ); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); + + it('applies padding by default', () => { + const { container } = renderWithProviders(Content); + const contentDiv = container.querySelector('.p-6'); + expect(contentDiv).toBeInTheDocument(); + }); + + it('removes padding when padding=false', () => { + const { container } = renderWithProviders(Content); + // The child div should not have p-6 class since padding is false + const divs = container.querySelectorAll('div'); + const hasP6 = Array.from(divs).some(d => d.className.includes('p-6')); + expect(hasP6).toBe(false); + }); + + it('applies custom className', () => { + const { container } = renderWithProviders(Content); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + + it('renders with border by default', () => { + const { container } = renderWithProviders(Content); + expect(container.firstElementChild?.className).toContain('border'); + }); +}); diff --git a/frontend/src/components/ui/Modal.test.tsx b/frontend/src/components/ui/Modal.test.tsx new file mode 100644 index 0000000..62affcb --- /dev/null +++ b/frontend/src/components/ui/Modal.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithProviders, screen, fireEvent } from '../../test/test-utils'; +import Modal from './Modal'; + +describe('Modal', () => { + const onClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when not open', () => { + renderWithProviders( + Content + ); + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + }); + + it('renders children when open', () => { + renderWithProviders( + Modal Content + ); + expect(screen.getByText('Modal Content')).toBeInTheDocument(); + }); + + it('renders title', () => { + renderWithProviders( + Content + ); + expect(screen.getByText('My Modal')).toBeInTheDocument(); + }); + + it('renders footer', () => { + renderWithProviders( + Save}>Content + ); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('calls onClose when backdrop is clicked', () => { + const { container } = renderWithProviders( + Content + ); + const backdrop = container.querySelector('.bg-black\\/50'); + if (backdrop) fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose on Escape key', () => { + renderWithProviders( + Content + ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders close button in title header', () => { + renderWithProviders( + Content + ); + const closeBtn = screen.getByRole('button'); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/ui/StatCard.test.tsx b/frontend/src/components/ui/StatCard.test.tsx new file mode 100644 index 0000000..a5875db --- /dev/null +++ b/frontend/src/components/ui/StatCard.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { renderWithProviders, screen } from '../../test/test-utils'; +import StatCard from './StatCard'; + +describe('StatCard', () => { + it('renders title and value', () => { + renderWithProviders(); + expect(screen.getByText('Total')).toBeInTheDocument(); + expect(screen.getByText('1,234')).toBeInTheDocument(); + }); + + it('renders subtitle', () => { + renderWithProviders(); + expect(screen.getByText('24h change')).toBeInTheDocument(); + }); + + it('renders icon', () => { + renderWithProviders( + I} /> + ); + expect(screen.getByTestId('stat-icon')).toBeInTheDocument(); + }); + + it('renders default variant (white bg)', () => { + const { container } = renderWithProviders(); + expect(container.firstElementChild?.className).toContain('bg-white'); + }); + + it('renders gradient-green variant', () => { + const { container } = renderWithProviders( + + ); + expect(container.firstElementChild?.className).toContain('from-emerald-500'); + }); + + it('renders gradient-indigo variant', () => { + const { container } = renderWithProviders( + + ); + expect(container.firstElementChild?.className).toContain('from-indigo-500'); + }); + + it('renders positive trend', () => { + renderWithProviders( + + ); + expect(screen.getByText('15%')).toBeInTheDocument(); + expect(screen.getByText('vs last month')).toBeInTheDocument(); + }); + + it('renders negative trend', () => { + renderWithProviders( + + ); + expect(screen.getByText('5%')).toBeInTheDocument(); + }); + + it('renders numeric value', () => { + renderWithProviders(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index c12239a..3dd97a6 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -2,11 +2,13 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { useTokenStore } from '../store/tokenStore'; import { useAuthStore } from '../store/authStore'; +import type { WebSocketEventName, IWebSocketEventMap } from '@etp/shared'; let socket: Socket | null = null; function getSocket() { if (!socket) { + const token = localStorage.getItem('token'); socket = io('/events', { transports: ['websocket'], autoConnect: false, @@ -14,21 +16,13 @@ function getSocket() { reconnectionAttempts: 10, reconnectionDelay: 1000, reconnectionDelayMax: 5000, + auth: { token }, }); } return socket; } -export type WebSocketEvent = - | 'trade:matched' - | 'order:updated' - | 'meter:reading' - | 'settlement:completed' - | 'stats:update' - | 'price:update' - | 'rec:update'; - -const WS_EVENTS: WebSocketEvent[] = [ +const WS_EVENTS: WebSocketEventName[] = [ 'trade:matched', 'order:updated', 'meter:reading', @@ -85,14 +79,17 @@ export function useWebSocket() { }; }, [isAuthenticated, userId, fetchBalance]); - const on = useCallback((event: WebSocketEvent, handler: (data: any) => void) => { + const on = useCallback(( + event: E, + handler: (data: IWebSocketEventMap[E]) => void, + ) => { if (!listenersRef.current.has(event)) { listenersRef.current.set(event, new Set()); } - listenersRef.current.get(event)!.add(handler); + listenersRef.current.get(event)!.add(handler as (data: any) => void); return () => { - listenersRef.current.get(event)?.delete(handler); + listenersRef.current.get(event)?.delete(handler as (data: any) => void); }; }, []); @@ -103,13 +100,16 @@ export function useWebSocket() { * 특정 WebSocket 이벤트를 구독하는 편의 훅. * 컴포넌트 마운트 시 자동 구독, 언마운트 시 자동 해제. */ -export function useSocketEvent(event: WebSocketEvent, handler: (data: any) => void) { +export function useSocketEvent( + event: E, + handler: (data: IWebSocketEventMap[E]) => void, +) { const { on } = useWebSocket(); const handlerRef = useRef(handler); handlerRef.current = handler; useEffect(() => { - const stableHandler = (data: any) => handlerRef.current(data); + const stableHandler = (data: IWebSocketEventMap[E]) => handlerRef.current(data); const unsub = on(event, stableHandler); return unsub; }, [on, event]); diff --git a/frontend/src/lib/csv-export.ts b/frontend/src/lib/csv-export.ts new file mode 100644 index 0000000..b8882a9 --- /dev/null +++ b/frontend/src/lib/csv-export.ts @@ -0,0 +1,46 @@ +interface CSVColumn { + key: keyof T | string; + label: string; + format?: (value: any, row: T) => string; +} + +/** + * 데이터를 CSV로 내보내기 + * BOM 포함하여 한국어 Excel 호환 + */ +export function exportToCSV>( + data: T[], + columns: CSVColumn[], + filename: string, +) { + if (data.length === 0) return; + + const header = columns.map((col) => `"${col.label}"`).join(','); + const rows = data.map((row) => + columns + .map((col) => { + const value = col.format + ? col.format(getNestedValue(row, col.key as string), row) + : getNestedValue(row, col.key as string); + const str = value == null ? '' : String(value); + return `"${str.replace(/"/g, '""')}"`; + }) + .join(','), + ); + + // BOM for Korean Excel compatibility + const BOM = '\uFEFF'; + const csv = BOM + [header, ...rows].join('\r\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${filename}_${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +function getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); +} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts new file mode 100644 index 0000000..95e61e3 --- /dev/null +++ b/frontend/src/lib/format.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { relativeTime } from './format'; + +describe('relativeTime', () => { + it('returns "방금 전" for recent times', () => { + const now = new Date(); + expect(relativeTime(now)).toBe('방금 전'); + }); + + it('returns "방금 전" for 30 seconds ago', () => { + const date = new Date(Date.now() - 30 * 1000); + expect(relativeTime(date)).toBe('방금 전'); + }); + + it('returns "N분 전" for minutes', () => { + const date = new Date(Date.now() - 5 * 60 * 1000); + expect(relativeTime(date)).toBe('5분 전'); + }); + + it('returns "N시간 전" for hours', () => { + const date = new Date(Date.now() - 3 * 60 * 60 * 1000); + expect(relativeTime(date)).toBe('3시간 전'); + }); + + it('returns "N일 전" for days', () => { + const date = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + expect(relativeTime(date)).toBe('2일 전'); + }); + + it('returns "N주 전" for weeks', () => { + const date = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + expect(relativeTime(date)).toBe('2주 전'); + }); + + it('returns "N개월 전" for months', () => { + const date = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); + expect(relativeTime(date)).toBe('2개월 전'); + }); + + it('returns "N년 전" for years', () => { + const date = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); + expect(relativeTime(date)).toBe('1년 전'); + }); + + it('handles string dates', () => { + const dateStr = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + expect(relativeTime(dateStr)).toBe('10분 전'); + }); + + it('returns "방금 전" for future dates', () => { + const future = new Date(Date.now() + 60000); + expect(relativeTime(future)).toBe('방금 전'); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..a882513 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,29 @@ +/** 상대 시간 표시 (한국어) */ +export function relativeTime(date: string | Date): string { + const now = Date.now(); + const target = new Date(date).getTime(); + const diff = now - target; + + if (diff < 0) return '방금 전'; + + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return '방금 전'; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}분 전`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + + const days = Math.floor(hours / 24); + if (days < 7) return `${days}일 전`; + + const weeks = Math.floor(days / 7); + if (weeks < 4) return `${weeks}주 전`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}개월 전`; + + const years = Math.floor(days / 365); + return `${years}년 전`; +} diff --git a/frontend/src/lib/schemas/auth.schema.ts b/frontend/src/lib/schemas/auth.schema.ts new file mode 100644 index 0000000..1c4ee66 --- /dev/null +++ b/frontend/src/lib/schemas/auth.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + email: z.email('유효한 이메일 주소를 입력해주세요'), + password: z.string().min(1, '비밀번호를 입력해주세요'), +}); + +export type LoginFormData = z.infer; + +export const registerSchema = z.object({ + name: z + .string() + .min(2, '이름은 최소 2자 이상이어야 합니다') + .max(50, '이름은 50자 이하여야 합니다'), + email: z.email('유효한 이메일 주소를 입력해주세요'), + password: z + .string() + .min(8, '비밀번호는 최소 8자 이상이어야 합니다') + .regex(/[a-z]/, '소문자가 최소 1개 포함되어야 합니다') + .regex(/[A-Z]/, '대문자가 최소 1개 포함되어야 합니다') + .regex(/\d/, '숫자가 최소 1개 포함되어야 합니다') + .regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, '특수문자가 최소 1개 포함되어야 합니다'), + role: z.enum(['SUPPLIER', 'CONSUMER'], { error: '역할을 선택해주세요' }), + organization: z.string().min(1, '조직명을 입력해주세요'), +}); + +export type RegisterFormData = z.infer; diff --git a/frontend/src/lib/schemas/trading.schema.ts b/frontend/src/lib/schemas/trading.schema.ts new file mode 100644 index 0000000..510c4d1 --- /dev/null +++ b/frontend/src/lib/schemas/trading.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const createOrderSchema = z.object({ + type: z.enum(['BUY', 'SELL'], { error: '주문 유형을 선택하세요' }), + energySource: z.enum(['SOLAR', 'WIND', 'HYDRO', 'BIOMASS', 'GEOTHERMAL'], { + error: '에너지원을 선택하세요', + }), + quantity: z + .number({ error: '수량을 입력하세요' }) + .positive('수량은 0보다 커야 합니다') + .max(100000, '최대 100,000 kWh까지 주문 가능합니다'), + price: z + .number({ error: '가격을 입력하세요' }) + .positive('가격은 0보다 커야 합니다'), + paymentCurrency: z.enum(['KRW', 'EPC'], { error: '결제 통화를 선택하세요' }), + validFrom: z.string().min(1, '시작 시간을 입력하세요'), + validUntil: z.string().min(1, '종료 시간을 입력하세요'), +}).refine( + (data) => { + if (data.validFrom && data.validUntil) { + return new Date(data.validUntil) > new Date(data.validFrom); + } + return true; + }, + { message: '종료 시간은 시작 시간 이후여야 합니다', path: ['validUntil'] }, +); + +export type CreateOrderFormData = z.infer; diff --git a/frontend/src/lib/schemas/wallet.schema.ts b/frontend/src/lib/schemas/wallet.schema.ts new file mode 100644 index 0000000..23e8789 --- /dev/null +++ b/frontend/src/lib/schemas/wallet.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const transferSchema = z.object({ + toUserId: z.string().uuid('유효한 사용자 UUID를 입력해주세요'), + amount: z + .number({ error: '수량을 입력해주세요' }) + .positive('수량은 0보다 커야 합니다') + .max(1000000, '최대 1,000,000 EPC까지 이체 가능합니다'), + reason: z.string().optional(), +}); + +export type TransferFormData = z.infer; diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 16028ef..a335f28 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,23 +1,95 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { analyticsService } from '../services/analytics.service'; import { tokenService } from '../services/token.service'; import { Card, Badge, Button, StatCard } from '../components/ui'; import { useToast } from '../components/ui/Toast'; +import type { IPlatformStats } from '@etp/shared'; -interface PlatformStats { - users: { total: number; byRole: Record }; - orders: { total: number }; - trades: { total: number; totalVolume: number; totalAmount: number; averagePrice: number }; - settlements: { completed: number; totalAmount: number; totalFees: number }; +type AdminTab = 'stats' | 'users' | 'disputes' | 'orders'; + +interface AdminUser { + id: string; email: string; name: string; role: string; + organization: string; status: string; createdAt: string; + did: string | null; didStatus: string | null; + orderCount: number; tradeCount: number; +} + +interface DisputeTrade { + id: string; buyerId: string; sellerId: string; energySource: string; + quantity: number; price: number; totalAmount: number; status: string; + createdAt: string; updatedAt: string; + buyer: { id: string; name: string; email: string; organization: string }; + seller: { id: string; name: string; email: string; organization: string }; + settlement: any; +} + +interface AdminOrder { + id: string; type: string; energySource: string; quantity: number; + price: number; remainingQty: number; paymentCurrency: string; + status: string; createdAt: string; + user: { id: string; name: string; email: string; organization: string; role: string }; } const ROLE_LABELS: Record = { SUPPLIER: '공급자', CONSUMER: '수요자', ADMIN: '관리자' }; +const STATUS_LABELS: Record = { ACTIVE: '활성', INACTIVE: '비활성', SUSPENDED: '정지' }; +const SOURCE_LABELS: Record = { SOLAR: '태양광', WIND: '풍력', HYDRO: '수력', BIOMASS: '바이오매스', GEOTHERMAL: '지열' }; +const ORDER_STATUS: Record = { + PENDING: { text: '대기', variant: 'warning' }, + PARTIALLY_FILLED: { text: '부분체결', variant: 'info' }, + FILLED: { text: '체결완료', variant: 'success' }, + CANCELLED: { text: '취소', variant: 'error' }, + EXPIRED: { text: '만료', variant: 'neutral' }, +}; + +const TAB_ITEMS: { key: AdminTab; label: string; icon: string }[] = [ + { key: 'stats', label: '통계', icon: '📊' }, + { key: 'users', label: '사용자', icon: '👥' }, + { key: 'disputes', label: '분쟁', icon: '⚖️' }, + { key: 'orders', label: '주문', icon: '📋' }, +]; export default function Admin() { - const [platformStats, setPlatformStats] = useState(null); + const [activeTab, setActiveTab] = useState('stats'); + const { toast } = useToast(); + + return ( +
+
+

관리자 패널

+

플랫폼 시스템 상태와 통계를 관리하세요

+
+ + {/* Tab Navigation */} +
+ {TAB_ITEMS.map((tab) => ( + + ))} +
+ + {activeTab === 'stats' && } + {activeTab === 'users' && } + {activeTab === 'disputes' && } + {activeTab === 'orders' && } +
+ ); +} + +// ─── Stats Tab ─── +function StatsTab({ toast }: { toast: (type: 'success' | 'error' | 'warning' | 'info', msg: string) => void }) { + const [platformStats, setPlatformStats] = useState(null); const [mintForm, setMintForm] = useState({ userId: '', amount: 0, reason: '' }); const [isLoading, setIsLoading] = useState(true); - const { toast } = useToast(); useEffect(() => { loadStats(); }, []); @@ -27,7 +99,7 @@ export default function Admin() { const stats = await analyticsService.getPlatformStats(); setPlatformStats(stats); } catch { - // ignore + toast('error', '통계 로드 실패'); } finally { setIsLoading(false); } @@ -46,46 +118,19 @@ export default function Admin() { const inputClass = "w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none"; - return ( -
-
-

관리자 패널

-

플랫폼 시스템 상태와 통계를 관리하세요

-
+ if (isLoading) return
로딩 중...
; - {/* Top Stats */} + return ( + <> {platformStats && (
- 👥} - /> - ⚡} - /> - 💰} - /> - 💎} - /> + 👥} /> + ⚡} /> + 💰} /> + 💎} />
)} -
- {/* System Status */}
@@ -96,102 +141,294 @@ export default function Admin() {
- - {/* Blockchain Info */} - -
- - - - -
-

체인코드 목록

-
- {['DID', 'Trading', 'Settlement', 'Metering', 'EPC', 'REC Token'].map((cc) => ( - {cc} - ))} -
-
-
-
- - {/* Platform Stats Detail */} - {platformStats && ( - -
-
-

사용자 구성

-
- {Object.entries(platformStats.users.byRole).map(([role, count]) => ( -
-
- {role === 'SUPPLIER' ? '☀️' : role === 'CONSUMER' ? '🏢' : '⚙️'} - {ROLE_LABELS[role] || role} -
-
-
-
-
- {count} -
-
- ))} -
-
-
- - - - -
-
- - )} - - {/* Admin EPC Mint */}
- setMintForm((f) => ({ ...f, userId: e.target.value }))} - placeholder="사용자 UUID" - className={inputClass} - required - /> + setMintForm((f) => ({ ...f, userId: e.target.value }))} placeholder="사용자 UUID" className={inputClass} required />
- setMintForm((f) => ({ ...f, amount: Number(e.target.value) }))} - className={inputClass} - required - /> + setMintForm((f) => ({ ...f, amount: Number(e.target.value) }))} className={inputClass} required />
- setMintForm((f) => ({ ...f, reason: e.target.value }))} - placeholder="발행 사유를 입력하세요" - className={inputClass} - /> + setMintForm((f) => ({ ...f, reason: e.target.value }))} placeholder="발행 사유를 입력하세요" className={inputClass} />
- +
+ + ); +} + +// ─── Users Tab ─── +function UsersTab({ toast }: { toast: (type: 'success' | 'error' | 'warning' | 'info', msg: string) => void }) { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const loadUsers = useCallback(async () => { + setIsLoading(true); + try { + const data = await analyticsService.getAdminUsers(); + setUsers(data); + } catch { + toast('error', '사용자 목록 로드 실패'); + } finally { + setIsLoading(false); + } + }, [toast]); + + useEffect(() => { loadUsers(); }, [loadUsers]); + + const handleDeactivate = async (userId: string) => { + if (!confirm('이 사용자를 비활성화하시겠습니까? DID도 함께 폐기됩니다.')) return; + try { + await analyticsService.deactivateUser(userId); + toast('success', '사용자가 비활성화되었습니다'); + loadUsers(); + } catch (err: any) { + toast('error', err.response?.data?.message || '비활성화 실패'); + } + }; + + if (isLoading) return
로딩 중...
; + + return ( + +
+ + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + + ))} + +
이름이메일역할조직상태DID주문/거래작업
{user.name}{user.email}{ROLE_LABELS[user.role] || user.role}{user.organization} + + {STATUS_LABELS[user.status] || user.status} + + + {user.did ? ( + {user.didStatus} + ) : ( + 미발급 + )} + {user.orderCount} / {user.tradeCount} + {user.status === 'ACTIVE' && user.role !== 'ADMIN' && ( + + )} +
+
+
+ ); +} + +// ─── Disputes Tab ─── +function DisputesTab({ toast }: { toast: (type: 'success' | 'error' | 'warning' | 'info', msg: string) => void }) { + const [disputes, setDisputes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const loadDisputes = useCallback(async () => { + setIsLoading(true); + try { + const data = await analyticsService.getDisputes(); + setDisputes(data); + } catch { + toast('error', '분쟁 목록 로드 실패'); + } finally { + setIsLoading(false); + } + }, [toast]); + + useEffect(() => { loadDisputes(); }, [loadDisputes]); + + const handleResolve = async (tradeId: string, resolution: 'REFUND' | 'COMPLETE' | 'CANCEL') => { + const labels = { REFUND: '환불', COMPLETE: '정산 확정', CANCEL: '거래 취소' }; + if (!confirm(`이 분쟁을 "${labels[resolution]}"로 해결하시겠습니까?`)) return; + try { + await analyticsService.resolveDispute(tradeId, resolution); + toast('success', `분쟁이 "${labels[resolution]}"로 해결되었습니다`); + loadDisputes(); + } catch (err: any) { + toast('error', err.response?.data?.message || '분쟁 해결 실패'); + } + }; + + if (isLoading) return
로딩 중...
; + + if (disputes.length === 0) { + return ( + +
+

⚖️

+

현재 진행 중인 분쟁이 없습니다

+
+
+ ); + } + + return ( +
+

분쟁 중 거래 ({disputes.length}건)

+ {disputes.map((d) => ( + +
+
+
+ 분쟁 + {SOURCE_LABELS[d.energySource] || d.energySource} + {d.quantity.toLocaleString()} kWh @ {d.price.toLocaleString()} +
+ {new Date(d.updatedAt).toLocaleString()} +
+
+
+

매수자

+

{d.buyer.name} ({d.buyer.organization})

+

{d.buyer.email}

+
+
+

매도자

+

{d.seller.name} ({d.seller.organization})

+

{d.seller.email}

+
+
+
+ + + +
+
+
+ ))}
); } +// ─── Orders Tab ─── +function OrdersTab({ toast }: { toast: (type: 'success' | 'error' | 'warning' | 'info', msg: string) => void }) { + const [orders, setOrders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(''); + + const loadOrders = useCallback(async () => { + setIsLoading(true); + try { + const data = await analyticsService.getAdminOrders(statusFilter ? { status: statusFilter } : undefined); + setOrders(data); + } catch { + toast('error', '주문 목록 로드 실패'); + } finally { + setIsLoading(false); + } + }, [toast, statusFilter]); + + useEffect(() => { loadOrders(); }, [loadOrders]); + + const handleCancel = async (orderId: string) => { + if (!confirm('이 주문을 강제 취소하시겠습니까?')) return; + try { + await analyticsService.adminCancelOrder(orderId); + toast('success', '주문이 취소되었습니다'); + loadOrders(); + } catch (err: any) { + toast('error', err.response?.data?.message || '주문 취소 실패'); + } + }; + + return ( + + {/* Filter */} +
+ {['', 'PENDING', 'PARTIALLY_FILLED', 'FILLED', 'CANCELLED', 'EXPIRED'].map((s) => ( + + ))} +
+ + {isLoading ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + + {orders.map((order) => { + const st = ORDER_STATUS[order.status]; + return ( + + + + + + + + + + + ); + })} + +
유형에너지원수량 (kWh)가격사용자상태일시작업
+ {order.type === 'BUY' ? '매수' : '매도'} + {SOURCE_LABELS[order.energySource] || order.energySource}{order.quantity.toLocaleString()}{order.price.toLocaleString()} {order.paymentCurrency}{order.user.name}{st && {st.text}}{new Date(order.createdAt).toLocaleDateString()} + {(order.status === 'PENDING' || order.status === 'PARTIALLY_FILLED') && ( + + )} +
+
+ )} +
+ ); +} + +// ─── Helper Components ─── function StatusItem({ label, status, detail }: { label: string; status: 'online' | 'offline'; detail?: string }) { return (
@@ -208,12 +445,3 @@ function StatusItem({ label, status, detail }: { label: string; status: 'online'
); } - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx new file mode 100644 index 0000000..ce76926 --- /dev/null +++ b/frontend/src/pages/Dashboard.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithProviders, screen, waitFor } from '../test/test-utils'; +import Dashboard from './Dashboard'; + +// Mock trading service +vi.mock('../services/trading.service', () => ({ + tradingService: { + getStats: vi.fn().mockResolvedValue({ + totalVolume: 50000, + totalTrades: 120, + averagePrice: 55.5, + todayVolume: 2000, + todayTrades: 8, + }), + getRecentTrades: vi.fn().mockResolvedValue([ + { + id: 'trade-1', + energySource: 'SOLAR', + quantity: 100, + price: 50, + totalAmount: 5000, + paymentCurrency: 'KRW', + buyerOrg: 'Buyer Corp', + sellerOrg: 'Seller Corp', + createdAt: new Date().toISOString(), + }, + ]), + }, +})); + +// Mock analytics service +vi.mock('../services/analytics.service', () => ({ + analyticsService: { + getPlatformStats: vi.fn().mockResolvedValue({ + users: { total: 50, byRole: { SUPPLIER: 20, CONSUMER: 25, ADMIN: 5 } }, + orders: { total: 300 }, + trades: { total: 120, totalVolume: 50000, totalAmount: 2750000, averagePrice: 55 }, + settlements: { completed: 80, totalAmount: 2200000, totalFees: 44000 }, + }), + getMonthlyTrend: vi.fn().mockResolvedValue({ monthly: [] }), + }, +})); + +// Mock oracle service +vi.mock('../services/oracle.service', () => ({ + oracleService: { + getLatestPrice: vi.fn().mockResolvedValue({ + weightedAvgPrice: 0.065, + eiaPrice: 0.07, + entsoePrice: 0.06, + kpxPrice: 0.065, + isStale: false, + }), + }, +})); + +// Mock token store +vi.mock('../store/tokenStore', () => ({ + useTokenStore: () => ({ + balance: 5000, + lockedBalance: 500, + fetchBalance: vi.fn(), + }), +})); + +// Mock WebSocket +vi.mock('../hooks/useWebSocket', () => ({ + useSocketEvent: vi.fn(), + useWebSocket: () => ({ on: vi.fn(), connected: false }), +})); + +// Mock recharts (avoid rendering issues in jsdom) +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + AreaChart: ({ children }: any) =>
{children}
, + Area: () => null, + PieChart: ({ children }: any) =>
{children}
, + Pie: () => null, + Cell: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Legend: () => null, +})); + +describe('Dashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders page header', () => { + renderWithProviders(); + expect(screen.getByText('대시보드')).toBeInTheDocument(); + }); + + it('renders subtitle', () => { + renderWithProviders(); + expect(screen.getByText(/RE100 전력거래 현황/)).toBeInTheDocument(); + }); + + it('renders EPC balance stat card', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('EPC 잔액')).toBeInTheDocument(); + }); + }); + + it('renders basket price stat card', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('바스켓 가격')).toBeInTheDocument(); + }); + }); + + it('renders total volume stat card', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('총 거래량')).toBeInTheDocument(); + }); + }); + + it('renders today volume stat card', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('오늘 거래량')).toBeInTheDocument(); + }); + }); + + it('renders platform overview mini cards', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('전체 사용자')).toBeInTheDocument(); + expect(screen.getByText('총 주문')).toBeInTheDocument(); + }); + }); + + it('renders recent trades section', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('최근 거래 활동')).toBeInTheDocument(); + }); + }); + + it('renders recent trade entry', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Buyer Corp')).toBeInTheDocument(); + expect(screen.getByText('Seller Corp')).toBeInTheDocument(); + }); + }); + + it('renders price sources section', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('글로벌 전력 가격 현황')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4ae0441..5c55f92 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -9,45 +9,18 @@ import { oracleService } from '../services/oracle.service'; import { useTokenStore } from '../store/tokenStore'; import { StatCard, Card } from '../components/ui'; import { useSocketEvent } from '../hooks/useWebSocket'; - -interface Stats { - totalVolume: number; - totalTrades: number; - totalAmount: number; - averagePrice: number; - todayVolume: number; - todayTrades: number; -} - -interface MonthlyTrend { - month: number; - tradeCount: number; - totalVolume: number; - totalAmount: number; -} - -interface PlatformStats { - users: { total: number; byRole: Record }; - orders: { total: number }; - trades: { total: number; totalVolume: number; totalAmount: number; averagePrice: number }; - settlements: { completed: number; totalAmount: number; totalFees: number }; -} - -interface PriceBasket { - weightedAvgPrice: number; - eiaPrice: number | null; - entsoePrice: number | null; - kpxPrice: number | null; - isStale: boolean; -} +import { relativeTime } from '../lib/format'; +import type { ITradingStats, IPlatformStats, IPriceBasketResponse, IMonthlyTrend, IRecentTrade } from '@etp/shared'; const COLORS = ['#22c55e', '#3b82f6', '#8b5cf6']; +const SOURCE_ICONS: Record = { SOLAR: '☀️', WIND: '🌬️', HYDRO: '💧', BIOMASS: '🌿', GEOTHERMAL: '🌋' }; export default function Dashboard() { - const [stats, setStats] = useState(null); - const [platformStats, setPlatformStats] = useState(null); - const [monthlyTrend, setMonthlyTrend] = useState([]); - const [latestPrice, setLatestPrice] = useState(null); + const [stats, setStats] = useState<(ITradingStats & { totalAmount?: number }) | null>(null); + const [platformStats, setPlatformStats] = useState(null); + const [monthlyTrend, setMonthlyTrend] = useState([]); + const [latestPrice, setLatestPrice] = useState(null); + const [recentTrades, setRecentTrades] = useState([]); const { balance, lockedBalance, fetchBalance } = useTokenStore(); const loadStats = useCallback(() => { @@ -55,24 +28,32 @@ export default function Dashboard() { analyticsService.getPlatformStats().then(setPlatformStats).catch(() => {}); }, []); + const loadRecentTrades = useCallback(() => { + tradingService.getRecentTrades(10).then(setRecentTrades).catch(() => {}); + }, []); + useEffect(() => { loadStats(); + loadRecentTrades(); analyticsService .getMonthlyTrend(new Date().getFullYear()) - .then((d: { monthly: MonthlyTrend[] }) => setMonthlyTrend(d.monthly)) + .then((d: { monthly: IMonthlyTrend[] }) => setMonthlyTrend(d.monthly)) .catch(() => {}); oracleService.getLatestPrice().then(setLatestPrice).catch(() => {}); fetchBalance(); }, []); // WebSocket 실시간 업데이트 - useSocketEvent('trade:matched', loadStats); + useSocketEvent('trade:matched', () => { + loadStats(); + loadRecentTrades(); + }); useSocketEvent('order:updated', loadStats); useSocketEvent('price:update', () => { oracleService.getLatestPrice().then(setLatestPrice).catch(() => {}); }); useSocketEvent('stats:update', (data) => { - if (data) setStats((prev) => prev ? { ...prev, ...data } : data); + if (data) setStats((prev) => prev ? { ...prev, ...data } : prev); }); const monthLabels = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; @@ -204,6 +185,46 @@ export default function Dashboard() {
+ {/* Recent Trades Feed */} + + {recentTrades.length > 0 ? ( +
+ {recentTrades.map((trade) => ( +
+
+ {SOURCE_ICONS[trade.energySource] || '⚡'} +
+
+ + {trade.sellerOrg} + + + + {trade.buyerOrg} + +
+

+ {trade.quantity.toLocaleString()} kWh @ {trade.price.toLocaleString()} {trade.paymentCurrency === 'EPC' ? 'EPC' : '원'} +

+
+
+
+

+ {trade.totalAmount.toLocaleString()} {trade.paymentCurrency === 'EPC' ? 'EPC' : '원'} +

+

{relativeTime(trade.createdAt)}

+
+
+ ))} +
+ ) : ( +
+ 📊 + 거래 내역이 없습니다 +
+ )} +
+ {/* Price Sources */} {latestPrice && ( diff --git a/frontend/src/pages/Login.test.tsx b/frontend/src/pages/Login.test.tsx new file mode 100644 index 0000000..f5e70b9 --- /dev/null +++ b/frontend/src/pages/Login.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderWithProviders, screen, fireEvent } from '../test/test-utils'; +import Login from './Login'; + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +// Mock auth store +vi.mock('../store/authStore', () => ({ + useAuthStore: () => ({ + login: vi.fn(), + register: vi.fn(), + didLogin: vi.fn(), + isLoading: false, + error: null, + }), +})); + +// Mock auth service +vi.mock('../services/auth.service', () => ({ + authService: { + requestDIDChallenge: vi.fn(), + loginWithDID: vi.fn(), + }, +})); + +describe('Login', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders login form by default', () => { + renderWithProviders(); + // The page renders the copyright footer and login heading + expect(screen.getByText(/RE100 전력 중개거래 플랫폼 ©/)).toBeInTheDocument(); + }); + + it('renders three auth tabs', () => { + renderWithProviders(); + // Tab buttons exist (로그인 appears as tab and heading, so use getAllByText) + const loginElements = screen.getAllByText('로그인'); + expect(loginElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('회원가입').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('DID 인증')).toBeInTheDocument(); + }); + + it('renders email and password fields', () => { + renderWithProviders(); + expect(screen.getByPlaceholderText('name@company.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + }); + + it('switches to register tab', () => { + renderWithProviders(); + // Click the 회원가입 tab (first occurrence is the tab button) + const registerElements = screen.getAllByText('회원가입'); + fireEvent.click(registerElements[0]); + // Register tab should show name field with placeholder 홍길동 + expect(screen.getByPlaceholderText('홍길동')).toBeInTheDocument(); + }); + + it('switches to DID tab', () => { + renderWithProviders(); + fireEvent.click(screen.getByText('DID 인증')); + expect(screen.getByPlaceholderText(/did:etp/)).toBeInTheDocument(); + }); + + it('renders login submit button', () => { + renderWithProviders(); + const buttons = screen.getAllByRole('button'); + const loginBtn = buttons.find(b => b.textContent === '로그인'); + expect(loginBtn).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index c6efac4..249b08c 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,38 +1,96 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useAuthStore } from '../store/authStore'; +import { authService } from '../services/auth.service'; +import { loginSchema, registerSchema, type LoginFormData, type RegisterFormData } from '../lib/schemas/auth.schema'; + +type AuthTab = 'login' | 'register' | 'did'; export default function Login() { const navigate = useNavigate(); - const { login, register, isLoading, error } = useAuthStore(); - const [isRegister, setIsRegister] = useState(false); - - const [form, setForm] = useState({ - email: '', - password: '', - name: '', - role: 'CONSUMER' as 'SUPPLIER' | 'CONSUMER' | 'ADMIN', - organization: '', + const { login, register: authRegister, didLogin, isLoading, error } = useAuthStore(); + const [activeTab, setActiveTab] = useState('login'); + + // Login form + const loginForm = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: '', password: '' }, }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + // Register form + const registerForm = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { name: '', email: '', password: '', role: 'CONSUMER', organization: '' }, + }); + + // DID login state (kept as useState - simpler for multi-step flow) + const [didForm, setDidForm] = useState({ did: '', signature: '' }); + const [challenge, setChallenge] = useState<{ challenge: string; expiresAt: string } | null>(null); + const [didLoading, setDidLoading] = useState(false); + const [didError, setDidError] = useState(null); + + const handleLoginSubmit = async (data: LoginFormData) => { try { - if (isRegister) { - await register(form); - } else { - await login({ email: form.email, password: form.password }); - } + await login(data); navigate('/'); } catch { // error is handled in store } }; - const updateForm = (field: string, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); + const handleRegisterSubmit = async (data: RegisterFormData) => { + try { + await authRegister(data); + navigate('/'); + } catch { + // error is handled in store + } + }; + + const handleRequestChallenge = async () => { + if (!didForm.did.trim()) return; + setDidLoading(true); + setDidError(null); + try { + const result = await authService.requestDIDChallenge(didForm.did); + setChallenge(result); + } catch (err: any) { + setDidError(err.response?.data?.message || '챌린지 요청에 실패했습니다'); + } finally { + setDidLoading(false); + } + }; + + const handleDIDLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!didForm.did || !didForm.signature) return; + try { + await didLogin(didForm.did, didForm.signature); + navigate('/'); + } catch { + // error handled in store + } + }; + + const handleTabChange = (tab: AuthTab) => { + setActiveTab(tab); + setChallenge(null); + setDidError(null); + loginForm.clearErrors(); + registerForm.clearErrors(); }; + const inputClass = 'w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow'; + const errorClass = 'text-xs text-red-500 mt-1'; + + const tabItems: { key: AuthTab; label: string }[] = [ + { key: 'login', label: '로그인' }, + { key: 'register', label: '회원가입' }, + { key: 'did', label: 'DID 인증' }, + ]; + return (
{/* Left side - branding */} @@ -85,119 +143,191 @@ export default function Login() {
-
-

- {isRegister ? '회원가입' : '로그인'} -

-

- {isRegister ? '새 계정을 만들어 플랫폼에 참여하세요' : '계정에 로그인하세요'} -

+ {/* Tab navigation */} +
+ {tabItems.map((tab) => ( + + ))}
-
- {isRegister && ( -
+ {/* Login form */} + {activeTab === 'login' && ( + <> +
+

로그인

+

계정에 로그인하세요

+
+ +
- - updateForm('name', e.target.value)} - placeholder="홍길동" - className="w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow" - required - /> + + + {loginForm.formState.errors.email &&

{loginForm.formState.errors.email.message}

}
+
- - updateForm('organization', e.target.value)} - placeholder="회사명 또는 조직명" - className="w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow" - required - /> + + + {loginForm.formState.errors.password &&

{loginForm.formState.errors.password.message}

} +
+ + {error && } + + + + + )} + + {/* Register form */} + {activeTab === 'register' && ( + <> +
+

회원가입

+

새 계정을 만들어 플랫폼에 참여하세요

+
+ +
+
+
+ + + {registerForm.formState.errors.name &&

{registerForm.formState.errors.name.message}

} +
+
+ + + {registerForm.formState.errors.organization &&

{registerForm.formState.errors.organization.message}

} +
+
+ +
+ registerForm.setValue('role', 'CONSUMER')} + icon="🏢" label="수요자" desc="RE100 참여기업" + /> + registerForm.setValue('role', 'SUPPLIER')} + icon="☀️" label="공급자" desc="발전사업자" + /> +
+ {registerForm.formState.errors.role &&

{registerForm.formState.errors.role.message}

} +
+
+ +
+ + + {registerForm.formState.errors.email &&

{registerForm.formState.errors.email.message}

}
+
- -
- updateForm('role', 'CONSUMER')} - icon="🏢" - label="수요자" - desc="RE100 참여기업" - /> - updateForm('role', 'SUPPLIER')} - icon="☀️" - label="공급자" - desc="발전사업자" + + + {registerForm.formState.errors.password &&

{registerForm.formState.errors.password.message}

} +
+ + {error && } + + + + + )} + + {/* DID Login */} + {activeTab === 'did' && ( +
+
+

DID 인증 로그인

+

분산 신원(DID) 기반 챌린지-응답 인증

+
+ +
+
+ +
+ setDidForm((f) => ({ ...f, did: e.target.value }))} + placeholder="did:etp:xxxx-xxxx" + className={inputClass} + required /> +
-
- )} - -
- - updateForm('email', e.target.value)} - placeholder="name@company.com" - className="w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow" - required - /> -
-
- - updateForm('password', e.target.value)} - placeholder="••••••••" - className="w-full px-3.5 py-2.5 border rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow" - required - minLength={8} - /> -
+ {challenge && ( +
+

챌린지 (서명 대상)

+

{challenge.challenge}

+

만료: {new Date(challenge.expiresAt).toLocaleTimeString()}

+
+ )} + +
+ +