test(auth,payments,subs): add 58 unit tests for critical auth, payment, and subscription paths
Cover auth handlers (RegisterUser, LoginUser, RefreshToken), TokenService (token rotation, reuse attack detection), payment callback edge cases (duplicate/concurrent callbacks, multi-provider), subscription lifecycle transitions (expire, pastDue, renew), and throttler proxy guard. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
34
.env.test
Normal file
34
.env.test
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# GoodGo Platform — Test Environment Variables
|
||||||
|
# Used by E2E tests (Playwright globalSetup loads this automatically)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Test database — separate from development DB for isolation
|
||||||
|
DATABASE_URL=postgresql://goodgo:goodgo_secret@localhost:5432/goodgo_test?schema=public
|
||||||
|
|
||||||
|
# Services (same as dev, adjust if your test infra differs)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
TYPESENSE_HOST=localhost
|
||||||
|
TYPESENSE_PORT=8108
|
||||||
|
TYPESENSE_PROTOCOL=http
|
||||||
|
TYPESENSE_API_KEY=ts_dev_key_change_me
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin_secret
|
||||||
|
MINIO_BUCKET=goodgo-uploads
|
||||||
|
|
||||||
|
# Auth (deterministic secrets for test reproducibility)
|
||||||
|
JWT_SECRET=e2e-test-jwt-secret-key
|
||||||
|
JWT_REFRESH_SECRET=e2e-test-refresh-secret-key
|
||||||
|
JWT_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
NODE_ENV=test
|
||||||
|
|
||||||
|
# Payment (sandbox)
|
||||||
|
VNPAY_TMN_CODE=TESTCODE
|
||||||
|
VNPAY_HASH_SECRET=TESTHASHSECRET
|
||||||
|
VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
|
||||||
|
VNPAY_RETURN_URL=http://localhost:3000/payment/return
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { LoginUserCommand } from '../commands/login-user/login-user.command';
|
||||||
|
import { LoginUserHandler } from '../commands/login-user/login-user.handler';
|
||||||
|
|
||||||
|
describe('LoginUserHandler', () => {
|
||||||
|
let handler: LoginUserHandler;
|
||||||
|
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const tokenPair = {
|
||||||
|
accessToken: 'access-jwt',
|
||||||
|
refreshToken: 'family.refresh-hex',
|
||||||
|
expiresIn: 900,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
||||||
|
handler = new LoginUserHandler(mockTokenService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates token pair with correct payload', async () => {
|
||||||
|
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result).toEqual(tokenPair);
|
||||||
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
|
sub: 'user-1',
|
||||||
|
phone: '0912345678',
|
||||||
|
role: 'BUYER',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes AGENT role correctly', async () => {
|
||||||
|
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
|
sub: 'user-2',
|
||||||
|
phone: '0987654321',
|
||||||
|
role: 'AGENT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes ADMIN role correctly', async () => {
|
||||||
|
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
|
||||||
|
sub: 'admin-1',
|
||||||
|
phone: '0901234567',
|
||||||
|
role: 'ADMIN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
|
import { HashedPassword } from '../../domain/value-objects/hashed-password.vo';
|
||||||
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
|
import { RefreshTokenCommand } from '../commands/refresh-token/refresh-token.command';
|
||||||
|
import { RefreshTokenHandler } from '../commands/refresh-token/refresh-token.handler';
|
||||||
|
|
||||||
|
function createActiveUser(): UserEntity {
|
||||||
|
const phone = Phone.create('0912345678').unwrap();
|
||||||
|
const pw = { value: 'hashed' } as HashedPassword;
|
||||||
|
return new UserEntity('user-1', {
|
||||||
|
email: null,
|
||||||
|
phone,
|
||||||
|
passwordHash: pw,
|
||||||
|
fullName: 'Test User',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'BUYER',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
kycData: null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInactiveUser(): UserEntity {
|
||||||
|
const phone = Phone.create('0912345678').unwrap();
|
||||||
|
const pw = { value: 'hashed' } as HashedPassword;
|
||||||
|
return new UserEntity('user-1', {
|
||||||
|
email: null,
|
||||||
|
phone,
|
||||||
|
passwordHash: pw,
|
||||||
|
fullName: 'Deactivated User',
|
||||||
|
avatarUrl: null,
|
||||||
|
role: 'BUYER',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
kycData: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RefreshTokenHandler', () => {
|
||||||
|
let handler: RefreshTokenHandler;
|
||||||
|
let mockTokenService: {
|
||||||
|
rotateRefreshToken: ReturnType<typeof vi.fn>;
|
||||||
|
generateAccessToken: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTokenService = {
|
||||||
|
rotateRefreshToken: vi.fn(),
|
||||||
|
generateAccessToken: vi.fn().mockReturnValue('new-access-jwt'),
|
||||||
|
};
|
||||||
|
mockUserRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPhone: vi.fn(),
|
||||||
|
findByEmail: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
handler = new RefreshTokenHandler(
|
||||||
|
mockTokenService as any,
|
||||||
|
mockUserRepo as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates refresh token and returns new token pair', async () => {
|
||||||
|
mockTokenService.rotateRefreshToken.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
refreshToken: 'new-family.new-refresh-hex',
|
||||||
|
});
|
||||||
|
mockUserRepo.findById.mockResolvedValue(createActiveUser());
|
||||||
|
|
||||||
|
const command = new RefreshTokenCommand('old-family.old-refresh-hex');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.accessToken).toBe('new-access-jwt');
|
||||||
|
expect(result.refreshToken).toBe('new-family.new-refresh-hex');
|
||||||
|
expect(result.expiresIn).toBe(900);
|
||||||
|
expect(mockTokenService.generateAccessToken).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when refresh token is invalid', async () => {
|
||||||
|
mockTokenService.rotateRefreshToken.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new RefreshTokenCommand('invalid-token');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow(
|
||||||
|
'Refresh token không hợp lệ hoặc đã hết hạn',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when user not found', async () => {
|
||||||
|
mockTokenService.rotateRefreshToken.mockResolvedValue({
|
||||||
|
userId: 'deleted-user',
|
||||||
|
refreshToken: 'new-family.new-hex',
|
||||||
|
});
|
||||||
|
mockUserRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new RefreshTokenCommand('family.valid-hex');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow(
|
||||||
|
'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when user is deactivated', async () => {
|
||||||
|
mockTokenService.rotateRefreshToken.mockResolvedValue({
|
||||||
|
userId: 'user-1',
|
||||||
|
refreshToken: 'new-family.new-hex',
|
||||||
|
});
|
||||||
|
mockUserRepo.findById.mockResolvedValue(createInactiveUser());
|
||||||
|
|
||||||
|
const command = new RefreshTokenCommand('family.valid-hex');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow(
|
||||||
|
'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
|
import { Phone } from '../../domain/value-objects/phone.vo';
|
||||||
|
import { RegisterUserCommand } from '../commands/register-user/register-user.command';
|
||||||
|
import { RegisterUserHandler } from '../commands/register-user/register-user.handler';
|
||||||
|
|
||||||
|
describe('RegisterUserHandler', () => {
|
||||||
|
let handler: RegisterUserHandler;
|
||||||
|
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const tokenPair = {
|
||||||
|
accessToken: 'access-jwt',
|
||||||
|
refreshToken: 'family.refresh-hex',
|
||||||
|
expiresIn: 900,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUserRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPhone: vi.fn().mockResolvedValue(null),
|
||||||
|
findByEmail: vi.fn().mockResolvedValue(null),
|
||||||
|
save: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn(),
|
||||||
|
};
|
||||||
|
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
|
||||||
|
handler = new RegisterUserHandler(
|
||||||
|
mockUserRepo as any,
|
||||||
|
mockTokenService as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a new user and returns token pair', async () => {
|
||||||
|
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result).toEqual(tokenPair);
|
||||||
|
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ phone: '+84912345678', role: 'BUYER' }),
|
||||||
|
);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers user with optional email', async () => {
|
||||||
|
const command = new RegisterUserCommand(
|
||||||
|
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'test@example.com',
|
||||||
|
);
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result).toEqual(tokenPair);
|
||||||
|
expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('test@example.com');
|
||||||
|
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when phone already exists', async () => {
|
||||||
|
mockUserRepo.findByPhone.mockResolvedValue({ id: 'existing-user' });
|
||||||
|
|
||||||
|
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được đăng ký');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException when email already exists', async () => {
|
||||||
|
mockUserRepo.findByEmail.mockResolvedValue({ id: 'existing-user' });
|
||||||
|
|
||||||
|
const command = new RegisterUserCommand(
|
||||||
|
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'taken@example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Email đã được đăng ký');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException for invalid phone number', async () => {
|
||||||
|
const command = new RegisterUserCommand('12345', 'StrongP@ss1', 'Nguyen Van A');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException for invalid email format', async () => {
|
||||||
|
const command = new RegisterUserCommand(
|
||||||
|
'0912345678', 'StrongP@ss1', 'Nguyen Van A', 'not-an-email',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes UserRegisteredEvent after saving', async () => {
|
||||||
|
const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A');
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ eventName: 'user.registered' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save user if phone validation fails', async () => {
|
||||||
|
const command = new RegisterUserCommand('invalid', 'StrongP@ss1', 'Nguyen Van A');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
expect(mockUserRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
|
||||||
|
import { TokenService } from '../services/token.service';
|
||||||
|
|
||||||
|
describe('TokenService', () => {
|
||||||
|
let service: TokenService;
|
||||||
|
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
||||||
|
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockJwtService = {
|
||||||
|
sign: vi.fn().mockReturnValue('signed-jwt'),
|
||||||
|
verify: vi.fn(),
|
||||||
|
};
|
||||||
|
mockRefreshTokenRepo = {
|
||||||
|
create: vi.fn().mockResolvedValue({} as RefreshTokenRecord),
|
||||||
|
findByToken: vi.fn(),
|
||||||
|
revokeByFamily: vi.fn().mockResolvedValue(undefined),
|
||||||
|
revokeAllForUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteExpired: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new TokenService(
|
||||||
|
mockJwtService as any,
|
||||||
|
mockRefreshTokenRepo as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTokenPair', () => {
|
||||||
|
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
|
||||||
|
const result = await service.generateTokenPair(payload);
|
||||||
|
|
||||||
|
expect(result.accessToken).toBe('signed-jwt');
|
||||||
|
expect(result.refreshToken).toContain('.');
|
||||||
|
expect(result.expiresIn).toBe(900);
|
||||||
|
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
||||||
|
expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
revokedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates refresh token record with 30-day expiry', async () => {
|
||||||
|
await service.generateTokenPair(payload);
|
||||||
|
|
||||||
|
const createCall = mockRefreshTokenRepo.create.mock.calls[0][0];
|
||||||
|
const expiresAt = createCall.expiresAt as Date;
|
||||||
|
const now = new Date();
|
||||||
|
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
expect(daysDiff).toBeGreaterThanOrEqual(29);
|
||||||
|
expect(daysDiff).toBeLessThanOrEqual(31);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotateRefreshToken', () => {
|
||||||
|
const makeExistingToken = (overrides?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({
|
||||||
|
id: 'rt-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
token: 'hashed-token',
|
||||||
|
family: 'old-family',
|
||||||
|
expiresAt: new Date(Date.now() + 86400000),
|
||||||
|
revokedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates valid token: revokes old family, creates new token', async () => {
|
||||||
|
mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken());
|
||||||
|
mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord);
|
||||||
|
|
||||||
|
const result = await service.rotateRefreshToken('old-family.raw-token-hex');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.userId).toBe('user-1');
|
||||||
|
expect(result!.refreshToken).toContain('.');
|
||||||
|
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family');
|
||||||
|
expect(mockRefreshTokenRepo.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for malformed token (no dot separator)', async () => {
|
||||||
|
const result = await service.rotateRefreshToken('no-dot-separator');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and revokes family when token not found (reuse attack)', async () => {
|
||||||
|
mockRefreshTokenRepo.findByToken.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.rotateRefreshToken('suspect-family.unknown-token');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and revokes family when token is already revoked', async () => {
|
||||||
|
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
||||||
|
makeExistingToken({ revokedAt: new Date() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.rotateRefreshToken('old-family.revoked-token');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null and revokes family when token is expired', async () => {
|
||||||
|
mockRefreshTokenRepo.findByToken.mockResolvedValue(
|
||||||
|
makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.rotateRefreshToken('old-family.expired-token');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty family segment', async () => {
|
||||||
|
const result = await service.rotateRefreshToken('.some-raw-token');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty raw token segment', async () => {
|
||||||
|
const result = await service.rotateRefreshToken('some-family.');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateAccessToken', () => {
|
||||||
|
it('delegates to jwtService.sign', () => {
|
||||||
|
const token = service.generateAccessToken(payload);
|
||||||
|
expect(token).toBe('signed-jwt');
|
||||||
|
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeAllUserTokens', () => {
|
||||||
|
it('revokes all tokens for a user', async () => {
|
||||||
|
await service.revokeAllUserTokens('user-1');
|
||||||
|
expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyAccessToken', () => {
|
||||||
|
it('returns decoded payload for valid token', () => {
|
||||||
|
mockJwtService.verify.mockReturnValue(payload);
|
||||||
|
const result = service.verifyAccessToken('valid-jwt');
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid token', () => {
|
||||||
|
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
|
||||||
|
const result = service.verifyAccessToken('bad-jwt');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||||
|
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||||
|
import { Money } from '../../domain/value-objects/money.vo';
|
||||||
|
import { HandleCallbackCommand } from '../commands/handle-callback/handle-callback.command';
|
||||||
|
import { HandleCallbackHandler } from '../commands/handle-callback/handle-callback.handler';
|
||||||
|
|
||||||
|
function createPaymentEntity(
|
||||||
|
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' = 'PROCESSING',
|
||||||
|
provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' = 'VNPAY',
|
||||||
|
): PaymentEntity {
|
||||||
|
const money = Money.create(500_000n).unwrap();
|
||||||
|
const payment = PaymentEntity.createNew('pay-1', 'user-1', provider, 'SUBSCRIPTION', money);
|
||||||
|
if (status === 'PROCESSING') payment.markProcessing('provider-tx-1');
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
payment.markProcessing('provider-tx-1');
|
||||||
|
payment.markCompleted({ verified: true });
|
||||||
|
}
|
||||||
|
if (status === 'FAILED') {
|
||||||
|
payment.markProcessing('provider-tx-1');
|
||||||
|
payment.markFailed({ reason: 'declined' });
|
||||||
|
}
|
||||||
|
payment.clearDomainEvents();
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HandleCallbackHandler — edge cases', () => {
|
||||||
|
let handler: HandleCallbackHandler;
|
||||||
|
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||||
|
let mockGateway: { verifyCallback: ReturnType<typeof vi.fn>; createPaymentUrl: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPaymentRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByProviderTxId: vi.fn(),
|
||||||
|
findByIdempotencyKey: vi.fn(),
|
||||||
|
findByUserId: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
updateIfStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGateway = {
|
||||||
|
verifyCallback: vi.fn(),
|
||||||
|
createPaymentUrl: vi.fn(),
|
||||||
|
refund: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
|
||||||
|
handler = new HandleCallbackHandler(
|
||||||
|
mockPaymentRepo as any,
|
||||||
|
mockGatewayFactory as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns idempotent response for duplicate success callback on COMPLETED payment', async () => {
|
||||||
|
const completedPayment = createPaymentEntity('COMPLETED');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'provider-tx-1',
|
||||||
|
isSuccess: true,
|
||||||
|
rawData: { responseCode: '00' },
|
||||||
|
});
|
||||||
|
// updateIfStatus returns null because payment is already COMPLETED
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||||
|
mockPaymentRepo.findById.mockResolvedValue(completedPayment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.paymentId).toBe('pay-1');
|
||||||
|
expect(result.status).toBe('COMPLETED');
|
||||||
|
expect(result.isSuccess).toBe(true);
|
||||||
|
// No new events should be published for idempotent response
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns idempotent response for duplicate callback on FAILED payment', async () => {
|
||||||
|
const failedPayment = createPaymentEntity('FAILED');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'provider-tx-1',
|
||||||
|
isSuccess: false,
|
||||||
|
rawData: { responseCode: '24' },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||||
|
mockPaymentRepo.findById.mockResolvedValue(failedPayment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '24' });
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.paymentId).toBe('pay-1');
|
||||||
|
expect(result.status).toBe('FAILED');
|
||||||
|
expect(result.isSuccess).toBe(false);
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses correct gateway for MoMo provider', async () => {
|
||||||
|
const payment = createPaymentEntity('PROCESSING', 'MOMO');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'momo-tx-1',
|
||||||
|
isSuccess: true,
|
||||||
|
rawData: { resultCode: 0 },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('MOMO', { resultCode: 0 });
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('MOMO');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses correct gateway for ZaloPay provider', async () => {
|
||||||
|
const payment = createPaymentEntity('PROCESSING', 'ZALOPAY');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'zalo-tx-1',
|
||||||
|
isSuccess: true,
|
||||||
|
rawData: { return_code: 1 },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('ZALOPAY', { return_code: 1 });
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('ZALOPAY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes PaymentFailedEvent on failed callback', async () => {
|
||||||
|
const payment = createPaymentEntity('PROCESSING');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'provider-tx-1',
|
||||||
|
isSuccess: false,
|
||||||
|
rawData: { responseCode: '99' },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '99' });
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.isSuccess).toBe(false);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ eventName: 'payment.failed' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes PaymentCompletedEvent on successful callback', async () => {
|
||||||
|
const payment = createPaymentEntity('PROCESSING');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'provider-tx-1',
|
||||||
|
isSuccess: true,
|
||||||
|
rawData: { responseCode: '00' },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.isSuccess).toBe(true);
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ eventName: 'payment.completed' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles concurrent callbacks — second callback gets idempotent response', async () => {
|
||||||
|
// Simulate: first callback succeeds, second callback arrives and updateIfStatus returns null
|
||||||
|
const completedPayment = createPaymentEntity('COMPLETED');
|
||||||
|
mockGateway.verifyCallback.mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
orderId: 'pay-1',
|
||||||
|
providerTxId: 'provider-tx-1',
|
||||||
|
isSuccess: true,
|
||||||
|
rawData: { responseCode: '00' },
|
||||||
|
});
|
||||||
|
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||||
|
mockPaymentRepo.findById.mockResolvedValue(completedPayment);
|
||||||
|
|
||||||
|
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.status).toBe('COMPLETED');
|
||||||
|
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ThrottlerBehindProxyGuard } from '../guards/throttler-behind-proxy.guard';
|
||||||
|
|
||||||
|
describe('ThrottlerBehindProxyGuard', () => {
|
||||||
|
let guard: ThrottlerBehindProxyGuard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create instance without DI — we only test the overridden getTracker method
|
||||||
|
guard = Object.create(ThrottlerBehindProxyGuard.prototype);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts first IP from X-Forwarded-For header', async () => {
|
||||||
|
const req = { headers: { 'x-forwarded-for': '203.0.113.1, 70.41.3.18, 150.172.238.178' }, ip: '127.0.0.1' };
|
||||||
|
// Access the protected method via bracket notation
|
||||||
|
const ip = await (guard as any).getTracker(req);
|
||||||
|
expect(ip).toBe('203.0.113.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from forwarded IP', async () => {
|
||||||
|
const req = { headers: { 'x-forwarded-for': ' 10.0.0.1 , 192.168.1.1' }, ip: '127.0.0.1' };
|
||||||
|
const ip = await (guard as any).getTracker(req);
|
||||||
|
expect(ip).toBe('10.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to req.ip when no X-Forwarded-For header', async () => {
|
||||||
|
const req = { headers: {}, ip: '192.168.1.100' };
|
||||||
|
const ip = await (guard as any).getTracker(req);
|
||||||
|
expect(ip).toBe('192.168.1.100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 127.0.0.1 when no IP available', async () => {
|
||||||
|
const req = { headers: {}, ip: undefined };
|
||||||
|
const ip = await (guard as any).getTracker(req);
|
||||||
|
expect(ip).toBe('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles array-typed x-forwarded-for by falling back to req.ip', async () => {
|
||||||
|
const req = { headers: { 'x-forwarded-for': ['203.0.113.1', '10.0.0.1'] }, ip: '172.16.0.1' };
|
||||||
|
const ip = await (guard as any).getTracker(req);
|
||||||
|
expect(ip).toBe('172.16.0.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { SubscriptionEntity } from '../entities/subscription.entity';
|
||||||
|
|
||||||
|
describe('SubscriptionEntity — lifecycle edge cases', () => {
|
||||||
|
const makeSub = (status?: 'ACTIVE' | 'PAST_DUE' | 'CANCELLED' | 'EXPIRED') => {
|
||||||
|
const sub = SubscriptionEntity.createNew(
|
||||||
|
'sub-1',
|
||||||
|
'user-1',
|
||||||
|
'plan-1',
|
||||||
|
'AGENT_PRO',
|
||||||
|
new Date('2026-01-01'),
|
||||||
|
new Date('2026-02-01'),
|
||||||
|
);
|
||||||
|
sub.clearDomainEvents();
|
||||||
|
|
||||||
|
if (status === 'PAST_DUE') sub.markPastDue();
|
||||||
|
if (status === 'CANCELLED') sub.cancel();
|
||||||
|
if (status === 'EXPIRED') sub.markExpired();
|
||||||
|
|
||||||
|
return sub;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('markExpired', () => {
|
||||||
|
it('transitions ACTIVE to EXPIRED', () => {
|
||||||
|
const sub = makeSub('ACTIVE');
|
||||||
|
sub.markExpired();
|
||||||
|
expect(sub.status).toBe('EXPIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions PAST_DUE to EXPIRED', () => {
|
||||||
|
const sub = makeSub('PAST_DUE');
|
||||||
|
sub.markExpired();
|
||||||
|
expect(sub.status).toBe('EXPIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when marking CANCELLED as expired', () => {
|
||||||
|
const sub = makeSub('CANCELLED');
|
||||||
|
expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when already expired', () => {
|
||||||
|
const sub = makeSub('EXPIRED');
|
||||||
|
expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markPastDue', () => {
|
||||||
|
it('transitions ACTIVE to PAST_DUE', () => {
|
||||||
|
const sub = makeSub('ACTIVE');
|
||||||
|
sub.markPastDue();
|
||||||
|
expect(sub.status).toBe('PAST_DUE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when marking PAST_DUE as past due again', () => {
|
||||||
|
const sub = makeSub('PAST_DUE');
|
||||||
|
expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when marking CANCELLED as past due', () => {
|
||||||
|
const sub = makeSub('CANCELLED');
|
||||||
|
expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when marking EXPIRED as past due', () => {
|
||||||
|
const sub = makeSub('EXPIRED');
|
||||||
|
expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renewPeriod', () => {
|
||||||
|
it('renews from PAST_DUE back to ACTIVE with new dates', () => {
|
||||||
|
const sub = makeSub('PAST_DUE');
|
||||||
|
const newStart = new Date('2026-02-01');
|
||||||
|
const newEnd = new Date('2026-03-01');
|
||||||
|
|
||||||
|
sub.renewPeriod(newStart, newEnd);
|
||||||
|
|
||||||
|
expect(sub.status).toBe('ACTIVE');
|
||||||
|
expect(sub.currentPeriodStart).toEqual(newStart);
|
||||||
|
expect(sub.currentPeriodEnd).toEqual(newEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renews from ACTIVE (extending period)', () => {
|
||||||
|
const sub = makeSub('ACTIVE');
|
||||||
|
const newStart = new Date('2026-03-01');
|
||||||
|
const newEnd = new Date('2026-04-01');
|
||||||
|
|
||||||
|
sub.renewPeriod(newStart, newEnd);
|
||||||
|
|
||||||
|
expect(sub.status).toBe('ACTIVE');
|
||||||
|
expect(sub.currentPeriodStart).toEqual(newStart);
|
||||||
|
expect(sub.currentPeriodEnd).toEqual(newEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renews from EXPIRED back to ACTIVE', () => {
|
||||||
|
const sub = makeSub('EXPIRED');
|
||||||
|
const newStart = new Date('2026-04-01');
|
||||||
|
const newEnd = new Date('2026-05-01');
|
||||||
|
|
||||||
|
sub.renewPeriod(newStart, newEnd);
|
||||||
|
|
||||||
|
expect(sub.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renews from CANCELLED back to ACTIVE', () => {
|
||||||
|
const sub = makeSub('CANCELLED');
|
||||||
|
const newStart = new Date('2026-04-01');
|
||||||
|
const newEnd = new Date('2026-05-01');
|
||||||
|
|
||||||
|
sub.renewPeriod(newStart, newEnd);
|
||||||
|
|
||||||
|
expect(sub.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isExpired', () => {
|
||||||
|
it('returns true when period end is in the past', () => {
|
||||||
|
const sub = SubscriptionEntity.createNew(
|
||||||
|
'sub-2',
|
||||||
|
'user-1',
|
||||||
|
'plan-1',
|
||||||
|
'FREE',
|
||||||
|
new Date('2020-01-01'),
|
||||||
|
new Date('2020-02-01'),
|
||||||
|
);
|
||||||
|
expect(sub.isExpired()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when period end is in the future', () => {
|
||||||
|
const sub = SubscriptionEntity.createNew(
|
||||||
|
'sub-3',
|
||||||
|
'user-1',
|
||||||
|
'plan-1',
|
||||||
|
'FREE',
|
||||||
|
new Date('2030-01-01'),
|
||||||
|
new Date('2030-02-01'),
|
||||||
|
);
|
||||||
|
expect(sub.isExpired()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upgrade constraints', () => {
|
||||||
|
it('throws when upgrading from PAST_DUE', () => {
|
||||||
|
const sub = makeSub('PAST_DUE');
|
||||||
|
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when upgrading from EXPIRED', () => {
|
||||||
|
const sub = makeSub('EXPIRED');
|
||||||
|
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel constraints', () => {
|
||||||
|
it('cancels from PAST_DUE', () => {
|
||||||
|
const sub = makeSub('PAST_DUE');
|
||||||
|
sub.cancel();
|
||||||
|
expect(sub.status).toBe('CANCELLED');
|
||||||
|
expect(sub.cancelledAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels from ACTIVE', () => {
|
||||||
|
const sub = makeSub('ACTIVE');
|
||||||
|
sub.cancel();
|
||||||
|
expect(sub.status).toBe('CANCELLED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
e2e/global-setup.ts
Normal file
55
e2e/global-setup.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright globalSetup — runs once before all E2E tests.
|
||||||
|
*
|
||||||
|
* 1. Loads .env.test (if present) so DATABASE_URL points to the test DB.
|
||||||
|
* 2. Runs Prisma migrations against the test database.
|
||||||
|
* 3. Seeds the test database with sample data.
|
||||||
|
*/
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const envTestPath = path.join(root, '.env.test');
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
|
||||||
|
// In CI, env vars are already set by the workflow; locally, load .env.test
|
||||||
|
if (!isCI) {
|
||||||
|
const { config } = await import('dotenv');
|
||||||
|
config({ path: envTestPath, override: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_URL is not set. Create .env.test or set it in your environment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In CI, the workflow already runs migrations + seed before Playwright.
|
||||||
|
// Skip to avoid duplicate work; only validate DATABASE_URL is set.
|
||||||
|
if (isCI) {
|
||||||
|
console.log('[E2E globalSetup] CI detected — skipping migrations/seed (handled by workflow).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[E2E globalSetup] Preparing test database...');
|
||||||
|
console.log(`[E2E globalSetup] DATABASE_URL = ${databaseUrl.replace(/\/\/.*@/, '//***@')}`);
|
||||||
|
|
||||||
|
const execOpts = {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit' as const,
|
||||||
|
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run migrations (deploy = no interactive prompts, safe for test)
|
||||||
|
console.log('[E2E globalSetup] Running prisma migrate deploy...');
|
||||||
|
execSync('npx prisma migrate deploy', execOpts);
|
||||||
|
|
||||||
|
// Seed database (upserts are idempotent)
|
||||||
|
console.log('[E2E globalSetup] Seeding test database...');
|
||||||
|
execSync('npx prisma db seed', execOpts);
|
||||||
|
|
||||||
|
console.log('[E2E globalSetup] Test database ready.\n');
|
||||||
|
}
|
||||||
78
e2e/global-teardown.ts
Normal file
78
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright globalTeardown — runs once after all E2E tests.
|
||||||
|
*
|
||||||
|
* Cleans up test-generated data (users, listings, etc.) while preserving
|
||||||
|
* seed data for the next run. This ensures isolation between test runs.
|
||||||
|
*/
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
|
||||||
|
if (!isCI) {
|
||||||
|
const { config } = await import('dotenv');
|
||||||
|
config({ path: path.join(root, '.env.test'), override: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.warn('[E2E globalTeardown] DATABASE_URL not set, skipping cleanup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[E2E globalTeardown] Cleaning up test-generated data...');
|
||||||
|
|
||||||
|
// Dynamic import to avoid top-level side effects
|
||||||
|
const pg = await import('pg');
|
||||||
|
const pool = new pg.default.Pool({ connectionString: databaseUrl });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete test-generated records (those NOT created by seed).
|
||||||
|
// Seed data uses known IDs (prop-1..prop-5, listing-1..listing-5)
|
||||||
|
// and known phones (0900000001..0900000005).
|
||||||
|
// Test fixtures generate users with phone starting with '09' + timestamp digits.
|
||||||
|
//
|
||||||
|
// Order matters due to foreign key constraints.
|
||||||
|
// Seed phones and IDs to preserve between runs
|
||||||
|
const SEED_PHONES = `('0900000001','0900000002','0900000003','0900000004','0900000005')`;
|
||||||
|
const SEED_LISTING_IDS = `('listing-1','listing-2','listing-3','listing-4','listing-5')`;
|
||||||
|
const SEED_PROP_IDS = `('prop-1','prop-2','prop-3','prop-4','prop-5')`;
|
||||||
|
const NON_SEED_USERS = `SELECT id FROM "User" WHERE phone NOT IN ${SEED_PHONES}`;
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
-- Delete test-generated data in dependency order (FK-safe)
|
||||||
|
DELETE FROM "NotificationLog" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "NotificationPreference" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Review" WHERE "reviewerId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Lead" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Inquiry" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Transaction" WHERE "sellerId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Payment" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "UsageRecord" WHERE "subscriptionId" IN (
|
||||||
|
SELECT s.id FROM "Subscription" s
|
||||||
|
JOIN "User" u ON s."userId" = u.id
|
||||||
|
WHERE u.phone NOT IN ${SEED_PHONES}
|
||||||
|
);
|
||||||
|
DELETE FROM "Subscription" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "Valuation" WHERE "propertyId" NOT IN ${SEED_PROP_IDS};
|
||||||
|
DELETE FROM "ListingMedia" WHERE "listingId" NOT IN ${SEED_LISTING_IDS};
|
||||||
|
DELETE FROM "Listing" WHERE id NOT IN ${SEED_LISTING_IDS};
|
||||||
|
DELETE FROM "PropertyMedia" WHERE "propertyId" NOT IN ${SEED_PROP_IDS};
|
||||||
|
DELETE FROM "Property" WHERE id NOT IN ${SEED_PROP_IDS};
|
||||||
|
DELETE FROM "Agent" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
-- RefreshToken and OAuthAccount cascade from User, but delete explicitly for safety
|
||||||
|
DELETE FROM "RefreshToken" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "OAuthAccount" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "SavedSearch" WHERE "userId" IN (${NON_SEED_USERS});
|
||||||
|
DELETE FROM "User" WHERE phone NOT IN ${SEED_PHONES};
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[E2E globalTeardown] Test data cleaned up successfully.\n');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[E2E globalTeardown] Cleanup error (non-fatal):', err);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"dependency-cruiser": "^17.3.10",
|
"dependency-cruiser": "^17.3.10",
|
||||||
|
"dotenv": "^17.4.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.4.0",
|
"lint-staged": "^16.4.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
// Load .env.test so webServer processes and tests use the test database
|
||||||
|
if (!process.env.CI) {
|
||||||
|
config({ path: path.resolve(__dirname, '.env.test'), override: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playwright E2E configuration for Goodgo Platform.
|
* Playwright E2E configuration for Goodgo Platform.
|
||||||
@@ -6,9 +13,15 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* Projects:
|
* Projects:
|
||||||
* - "api" — tests against the NestJS API (port 3001)
|
* - "api" — tests against the NestJS API (port 3001)
|
||||||
* - "web" — tests against the Next.js frontend (port 3000)
|
* - "web" — tests against the Next.js frontend (port 3000)
|
||||||
|
*
|
||||||
|
* Database isolation:
|
||||||
|
* - globalSetup runs migrations + seed on the test DB
|
||||||
|
* - globalTeardown cleans up test-generated data after all tests
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
globalTeardown: './e2e/global-teardown.ts',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
@@ -50,6 +63,7 @@ export default defineConfig({
|
|||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL ?? '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -18,6 +18,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.59.1
|
specifier: ^1.59.1
|
||||||
version: 1.59.1
|
version: 1.59.1
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
dependency-cruiser:
|
dependency-cruiser:
|
||||||
specifier: ^17.3.10
|
specifier: ^17.3.10
|
||||||
version: 17.3.10
|
version: 17.3.10
|
||||||
@@ -45,6 +48,9 @@ importers:
|
|||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^16.4.0
|
specifier: ^16.4.0
|
||||||
version: 16.4.0
|
version: 16.4.0
|
||||||
|
pg:
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user