251209:1453 Frontend: progress nest = UAT & Bug Fixing
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-09 14:53:42 +07:00
parent 8aceced902
commit aa96cd90e3
125 changed files with 11052 additions and 785 deletions

View File

@@ -1,30 +1,86 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
describe('AuthController', () => {
let controller: AuthController;
let mockAuthService: Partial<AuthService>;
@Post('login')
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser(
loginDto.username,
loginDto.password,
);
beforeEach(async () => {
mockAuthService = {
validateUser: jest.fn(),
login: jest.fn(),
register: jest.fn(),
refreshToken: jest.fn(),
logout: jest.fn(),
};
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
return this.authService.login(user);
}
controller = module.get<AuthController>(AuthController);
});
@Post('register-admin')
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
}
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('login', () => {
it('should return tokens when credentials are valid', async () => {
const loginDto = { username: 'test', password: 'password' };
const mockUser = { user_id: 1, username: 'test' };
const mockTokens = {
access_token: 'access_token',
refresh_token: 'refresh_token',
user: mockUser,
};
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(mockUser);
(mockAuthService.login as jest.Mock).mockResolvedValue(mockTokens);
const result = await controller.login(loginDto);
expect(mockAuthService.validateUser).toHaveBeenCalledWith(
'test',
'password'
);
expect(mockAuthService.login).toHaveBeenCalledWith(mockUser);
expect(result).toEqual(mockTokens);
});
it('should throw UnauthorizedException when credentials are invalid', async () => {
const loginDto = { username: 'test', password: 'wrong' };
(mockAuthService.validateUser as jest.Mock).mockResolvedValue(null);
await expect(controller.login(loginDto)).rejects.toThrow(
UnauthorizedException
);
});
});
describe('register', () => {
it('should register a new user', async () => {
const registerDto = {
username: 'newuser',
password: 'password',
email: 'test@test.com',
display_name: 'Test User',
};
const mockUser = { user_id: 1, ...registerDto };
(mockAuthService.register as jest.Mock).mockResolvedValue(mockUser);
const result = await controller.register(registerDto);
expect(mockAuthService.register).toHaveBeenCalledWith(registerDto);
});
});
});

View File

@@ -11,13 +11,15 @@ import {
Req,
HttpCode,
HttpStatus,
Delete,
Param,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js';
import { RegisterDto } from './dto/register.dto.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard';
import {
ApiTags,
ApiOperation,
@@ -130,4 +132,22 @@ export class AuthController {
getProfile(@Req() req: RequestWithUser) {
return req.user;
}
@UseGuards(JwtAuthGuard)
@Get('sessions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get active sessions' })
@ApiResponse({ status: 200, description: 'List of active sessions' })
async getSessions() {
return this.authService.getActiveSessions();
}
@UseGuards(JwtAuthGuard)
@Delete('sessions/:id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Revoke session' })
@ApiResponse({ status: 200, description: 'Session revoked' })
async revokeSession(@Param('id') id: string) {
return this.authService.revokeSession(parseInt(id));
}
}

View File

@@ -8,9 +8,18 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from '../../modules/user/entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UnauthorizedException } from '@nestjs/common';
// Mock bcrypt at top level
jest.mock('bcrypt', () => ({
compare: jest.fn(),
hash: jest.fn().mockResolvedValue('hashedpassword'),
genSalt: jest.fn().mockResolvedValue('salt'),
}));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const bcrypt = require('bcrypt');
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
@@ -42,6 +51,9 @@ describe('AuthService', () => {
};
beforeEach(async () => {
// Reset bcrypt mocks
bcrypt.compare.mockResolvedValue(true);
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
@@ -63,7 +75,7 @@ describe('AuthService', () => {
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key) => {
get: jest.fn().mockImplementation((key: string) => {
if (key.includes('EXPIRATION')) return '1h';
return 'secret';
}),
@@ -90,17 +102,6 @@ describe('AuthService', () => {
userService = module.get<UserService>(UserService);
jwtService = module.get<JwtService>(JwtService);
tokenRepo = module.get(getRepositoryToken(RefreshToken));
// Mock bcrypt
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(true));
jest
.spyOn(bcrypt, 'hash')
.mockImplementation(() => Promise.resolve('hashedpassword'));
jest
.spyOn(bcrypt, 'genSalt')
.mockImplementation(() => Promise.resolve('salt'));
});
afterEach(() => {
@@ -126,9 +127,7 @@ describe('AuthService', () => {
});
it('should return null if password mismatch', async () => {
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(false));
bcrypt.compare.mockResolvedValueOnce(false);
const result = await service.validateUser('testuser', 'wrongpassword');
expect(result).toBeNull();
});

View File

@@ -19,9 +19,9 @@ import type { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { UserService } from '../../modules/user/user.service.js';
import { UserService } from '../../modules/user/user.service';
import { User } from '../../modules/user/entities/user.entity';
import { RegisterDto } from './dto/register.dto.js';
import { RegisterDto } from './dto/register.dto';
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
@Injectable()
@@ -230,4 +230,43 @@ export class AuthService {
return { message: 'Logged out successfully' };
}
// [New] Get Active Sessions
async getActiveSessions() {
// Only return tokens that are NOT revoked and NOT expired
const activeTokens = await this.refreshTokenRepository.find({
where: {
isRevoked: false,
},
relations: ['user'], // Ensure relations: ['user'] works if RefreshToken entity has relation
order: { createdAt: 'DESC' },
});
const now = new Date();
// Filter expired tokens in memory if query builder is complex, or rely on where clause if possible.
// Since we want to return mapped data:
return activeTokens
.filter((t) => t.expiresAt > now)
.map((t) => ({
id: t.tokenId.toString(),
userId: t.userId,
user: {
username: t.user?.username || 'Unknown',
first_name: t.user?.firstName || '',
last_name: t.user?.lastName || '',
},
deviceName: 'Unknown Device', // Not stored in DB
ipAddress: 'Unknown IP', // Not stored in DB
lastActive: t.createdAt.toISOString(), // Best approximation
isCurrent: false, // Cannot determine isCurrent without current session context match
}));
}
// [New] Revoke Session by ID
async revokeSession(sessionId: number) {
return this.refreshTokenRepository.update(
{ tokenId: sessionId },
{ isRevoked: true }
);
}
}

View File

@@ -1,12 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageController } from './file-storage.controller';
import { FileStorageService } from './file-storage.service';
describe('FileStorageController', () => {
let controller: FileStorageController;
let mockFileStorageService: Partial<FileStorageService>;
beforeEach(async () => {
mockFileStorageService = {
upload: jest.fn(),
download: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [FileStorageController],
providers: [
{
provide: FileStorageService,
useValue: mockFileStorageService,
},
],
}).compile();
controller = module.get<FileStorageController>(FileStorageController);
@@ -15,4 +29,25 @@ describe('FileStorageController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('uploadFile', () => {
it('should upload a file successfully', async () => {
const mockFile = {
originalname: 'test.pdf',
buffer: Buffer.from('test'),
mimetype: 'application/pdf',
size: 100,
} as Express.Multer.File;
const mockResult = { attachment_id: 1, originalFilename: 'test.pdf' };
(mockFileStorageService.upload as jest.Mock).mockResolvedValue(
mockResult
);
const mockReq = { user: { userId: 1, username: 'testuser' } };
const result = await controller.uploadFile(mockFile, mockReq as any);
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
});
});
});

View File

@@ -18,8 +18,8 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
import { FileStorageService } from './file-storage.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
interface RequestWithUser {
@@ -47,10 +47,10 @@ export class FileStorageController {
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
}),
],
}),
})
)
file: Express.Multer.File,
@Request() req: RequestWithUser,
@Request() req: RequestWithUser
) {
// ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId);
@@ -63,7 +63,7 @@ export class FileStorageController {
@Get(':id/download')
async downloadFile(
@Param('id', ParseIntPipe) id: number,
@Res({ passthrough: true }) res: Response,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
const { stream, attachment } = await this.fileStorageService.download(id);
@@ -87,7 +87,7 @@ export class FileStorageController {
@Delete(':id')
async deleteFile(
@Param('id', ParseIntPipe) id: number,
@Request() req: RequestWithUser,
@Request() req: RequestWithUser
) {
// ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ
await this.fileStorageService.delete(id, req.user.userId);