feat(auth): implement Auth module with register, login, JWT, guards, and CQRS

- Add RefreshToken and OAuthAccount models to Prisma schema
- Implement clean architecture: domain (entities, VOs, events, repo interfaces),
  infrastructure (Prisma repos, Passport strategies, token service),
  application (CQRS command/query handlers), presentation (controller, guards, DTOs)
- Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile,
  GET /auth/profile/agent, PATCH /auth/kyc
- JWT access + refresh token rotation with family-based revocation
- Role-based guards (BUYER, SELLER, AGENT, ADMIN)
- 16 unit tests (value objects, entity) + integration test suite
- All 80 tests passing, clean TypeScript build

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 00:24:42 +07:00
parent c981bff771
commit 391c040100
63 changed files with 2194 additions and 33 deletions

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { type INestApplication, ValidationPipe } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import request from 'supertest';
import { AuthModule } from '../auth.module';
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { SharedModule } from '@modules/shared/shared.module';
describe('Auth Controller (Integration)', () => {
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [CqrsModule.forRoot(), SharedModule, AuthModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
prisma = app.get(PrismaService);
});
afterAll(async () => {
// Clean up test data
await prisma.refreshToken.deleteMany({});
await prisma.user.deleteMany({
where: { phone: { in: ['+84912345678', '+84987654321'] } },
});
await app.close();
});
describe('POST /auth/register', () => {
it('should register a new user and return tokens', async () => {
const res = await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0912345678',
password: 'StrongPass123',
fullName: 'Test User',
email: 'test@example.com',
})
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
expect(res.body.expiresIn).toBe(900);
});
it('should reject duplicate phone', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0912345678',
password: 'StrongPass123',
fullName: 'Duplicate User',
})
.expect(409);
});
it('should reject invalid phone', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '12345',
password: 'StrongPass123',
fullName: 'Invalid Phone',
})
.expect(400);
});
it('should reject short password', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({
phone: '0987654321',
password: 'short',
fullName: 'Short Pass',
})
.expect(400);
});
});
describe('POST /auth/login', () => {
it('should login with valid credentials', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
})
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
});
it('should reject invalid password', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'WrongPassword',
})
.expect(401);
});
it('should reject non-existent phone', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0999999999',
password: 'StrongPass123',
})
.expect(401);
});
});
describe('POST /auth/refresh', () => {
let refreshToken: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
});
refreshToken = res.body.refreshToken;
});
it('should rotate refresh token', async () => {
const res = await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
expect(res.body.refreshToken).not.toBe(refreshToken);
});
it('should reject reused refresh token', async () => {
// The old token was already rotated — reuse should fail
await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(401);
});
});
describe('GET /auth/profile', () => {
let accessToken: string;
beforeAll(async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({
phone: '0912345678',
password: 'StrongPass123',
});
accessToken = res.body.accessToken;
});
it('should return user profile', async () => {
const res = await request(app.getHttpServer())
.get('/auth/profile')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(res.body.phone).toBe('+84912345678');
expect(res.body.fullName).toBe('Test User');
expect(res.body.role).toBe('BUYER');
expect(res.body.kycStatus).toBe('NONE');
});
it('should reject unauthenticated request', async () => {
await request(app.getHttpServer()).get('/auth/profile').expect(401);
});
it('should reject invalid token', async () => {
await request(app.getHttpServer())
.get('/auth/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
});
});