efficiency & performance fine tuning

This commit is contained in:
2025-12-18 14:24:00 +08:00
parent fdd8a693e2
commit e3b56781e1
14 changed files with 392 additions and 224 deletions

View File

@@ -0,0 +1,33 @@
import { FriendRequest, User } from '@prisma/client';
export enum FriendEvents {
REQUEST_RECEIVED = 'friend.request.received',
REQUEST_ACCEPTED = 'friend.request.accepted',
REQUEST_DENIED = 'friend.request.denied',
UNFRIENDED = 'friend.unfriended',
}
export type FriendRequestWithRelations = FriendRequest & {
sender: User;
receiver: User;
};
export interface FriendRequestReceivedEvent {
userId: string;
friendRequest: FriendRequestWithRelations;
}
export interface FriendRequestAcceptedEvent {
userId: string;
friendRequest: FriendRequestWithRelations;
}
export interface FriendRequestDeniedEvent {
userId: string;
friendRequest: FriendRequestWithRelations;
}
export interface UnfriendedEvent {
userId: string;
friendId: string;
}

View File

@@ -1,9 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ThrottlerModule } from '@nestjs/throttler';
import { FriendsController } from './friends.controller';
import { FriendsService } from './friends.service';
import { UsersService } from '../users/users.service';
import { AuthService } from '../auth/auth.service';
import { StateGateway } from '../ws/state/state.gateway';
// StateGateway removed
enum FriendRequestStatus {
PENDING = 'PENDING',
@@ -85,21 +86,21 @@ describe('FriendsController', () => {
ensureUserExists: jest.fn(),
};
const mockStateGateway = {
emitFriendRequestReceived: jest.fn(),
emitFriendRequestAccepted: jest.fn(),
emitFriendRequestDenied: jest.fn(),
emitUnfriended: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 10,
},
]),
],
controllers: [FriendsController],
providers: [
{ provide: FriendsService, useValue: mockFriendsService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: AuthService, useValue: mockAuthService },
{ provide: StateGateway, useValue: mockStateGateway },
],
}).compile();
@@ -140,7 +141,7 @@ describe('FriendsController', () => {
});
describe('sendFriendRequest', () => {
it('should send friend request and emit WebSocket event', async () => {
it('should send friend request', async () => {
mockFriendsService.sendFriendRequest.mockResolvedValue(mockFriendRequest);
const result = await controller.sendFriendRequest(
@@ -170,10 +171,6 @@ describe('FriendsController', () => {
'user-1',
'user-2',
);
expect(mockStateGateway.emitFriendRequestReceived).toHaveBeenCalledWith(
'user-2',
mockFriendRequest,
);
});
});
@@ -210,7 +207,7 @@ describe('FriendsController', () => {
});
describe('acceptFriendRequest', () => {
it('should accept friend request and emit WebSocket event', async () => {
it('should accept friend request', async () => {
const acceptedRequest = {
...mockFriendRequest,
status: FriendRequestStatus.ACCEPTED,
@@ -227,15 +224,11 @@ describe('FriendsController', () => {
'request-1',
'user-1',
);
expect(mockStateGateway.emitFriendRequestAccepted).toHaveBeenCalledWith(
'user-1',
acceptedRequest,
);
});
});
describe('denyFriendRequest', () => {
it('should deny friend request and emit WebSocket event', async () => {
it('should deny friend request', async () => {
const deniedRequest = {
...mockFriendRequest,
status: FriendRequestStatus.DENIED,
@@ -252,10 +245,6 @@ describe('FriendsController', () => {
'request-1',
'user-1',
);
expect(mockStateGateway.emitFriendRequestDenied).toHaveBeenCalledWith(
'user-1',
deniedRequest,
);
});
});
@@ -282,7 +271,7 @@ describe('FriendsController', () => {
});
describe('unfriend', () => {
it('should unfriend user and emit WebSocket event', async () => {
it('should unfriend user', async () => {
mockFriendsService.unfriend.mockResolvedValue(undefined);
await controller.unfriend('user-2', mockAuthUser);
@@ -291,10 +280,6 @@ describe('FriendsController', () => {
'user-1',
'user-2',
);
expect(mockStateGateway.emitUnfriended).toHaveBeenCalledWith(
'user-2',
'user-1',
);
});
});
});

View File

@@ -19,6 +19,7 @@ import {
ApiUnauthorizedResponse,
ApiQuery,
} from '@nestjs/swagger';
import { ThrottlerGuard, Throttle } from '@nestjs/throttler';
import { User, FriendRequest } from '@prisma/client';
import { FriendsService } from './friends.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -40,7 +41,6 @@ type FriendRequestWithRelations = FriendRequest & {
receiver: User;
};
import { UsersService } from '../users/users.service';
import { StateGateway } from '../ws/state/state.gateway';
@ApiTags('friends')
@Controller('friends')
@@ -53,10 +53,11 @@ export class FriendsController {
private readonly friendsService: FriendsService,
private readonly usersService: UsersService,
private readonly authService: AuthService,
private readonly stateGateway: StateGateway,
) {}
@Get('search')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({
summary: 'Search users by username',
description: 'Search for users by username to send friend requests',
@@ -137,11 +138,6 @@ export class FriendsController {
sendRequestDto.receiverId,
);
this.stateGateway.emitFriendRequestReceived(
sendRequestDto.receiverId,
friendRequest,
);
return this.mapFriendRequestToDto(friendRequest);
}
@@ -236,11 +232,6 @@ export class FriendsController {
user.id,
);
this.stateGateway.emitFriendRequestAccepted(
friendRequest.senderId,
friendRequest,
);
return this.mapFriendRequestToDto(friendRequest);
}
@@ -283,11 +274,6 @@ export class FriendsController {
user.id,
);
this.stateGateway.emitFriendRequestDenied(
friendRequest.senderId,
friendRequest,
);
return this.mapFriendRequestToDto(friendRequest);
}
@@ -360,8 +346,6 @@ export class FriendsController {
this.logger.log(`User ${user.id} unfriending user ${friendId}`);
await this.friendsService.unfriend(user.id, friendId);
this.stateGateway.emitUnfriended(friendId, user.id);
}
private mapFriendRequestToDto(

View File

@@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { FriendsService } from './friends.service';
import { PrismaService } from '../database/prisma.service';
import {
@@ -15,6 +16,7 @@ enum FriendRequestStatus {
describe('FriendsService', () => {
let service: FriendsService;
let eventEmitter: EventEmitter2;
const mockUser1 = {
id: 'user-1',
@@ -82,6 +84,10 @@ describe('FriendsService', () => {
$transaction: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -90,10 +96,15 @@ describe('FriendsService', () => {
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: EventEmitter2,
useValue: mockEventEmitter,
},
],
}).compile();
service = module.get<FriendsService>(FriendsService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
});
@@ -110,6 +121,12 @@ describe('FriendsService', () => {
mockPrismaService.friendRequest.create.mockResolvedValue(
mockFriendRequest,
);
// Mock transaction implementation
mockPrismaService.$transaction.mockImplementation(
async (callback: (prisma: any) => Promise<any>) => {
return (await callback(mockPrismaService)) as unknown;
},
);
const result = await service.sendFriendRequest('user-1', 'user-2');
@@ -128,6 +145,7 @@ describe('FriendsService', () => {
receiver: true,
},
});
expect(mockEventEmitter.emit).toHaveBeenCalled();
});
it('should throw BadRequestException when trying to send request to self', async () => {
@@ -141,6 +159,11 @@ describe('FriendsService', () => {
it('should throw NotFoundException when receiver does not exist', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.$transaction.mockImplementation(
async (callback: (prisma: any) => Promise<any>) => {
return (await callback(mockPrismaService)) as unknown;
},
);
await expect(
service.sendFriendRequest('user-1', 'nonexistent'),
@@ -153,6 +176,11 @@ describe('FriendsService', () => {
it('should throw ConflictException when users are already friends', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
mockPrismaService.$transaction.mockImplementation(
async (callback: (prisma: any) => Promise<any>) => {
return (await callback(mockPrismaService)) as unknown;
},
);
await expect(
service.sendFriendRequest('user-1', 'user-2'),
@@ -168,6 +196,11 @@ describe('FriendsService', () => {
mockPrismaService.friendRequest.findFirst.mockResolvedValue(
mockFriendRequest,
);
mockPrismaService.$transaction.mockImplementation(
async (callback: (prisma: any) => Promise<any>) => {
return (await callback(mockPrismaService)) as unknown;
},
);
await expect(
service.sendFriendRequest('user-1', 'user-2'),
@@ -185,6 +218,11 @@ describe('FriendsService', () => {
senderId: 'user-2',
receiverId: 'user-1',
});
mockPrismaService.$transaction.mockImplementation(
async (callback: (prisma: any) => Promise<any>) => {
return (await callback(mockPrismaService)) as unknown;
},
);
await expect(
service.sendFriendRequest('user-1', 'user-2'),
@@ -259,6 +297,7 @@ describe('FriendsService', () => {
expect(result).toEqual(acceptedRequest);
expect(mockPrismaService.$transaction).toHaveBeenCalled();
expect(mockEventEmitter.emit).toHaveBeenCalled();
});
it('should throw NotFoundException when request does not exist', async () => {
@@ -320,6 +359,7 @@ describe('FriendsService', () => {
expect(mockPrismaService.friendRequest.delete).toHaveBeenCalledWith({
where: { id: 'request-1' },
});
expect(mockEventEmitter.emit).toHaveBeenCalled();
});
it('should throw NotFoundException when request does not exist', async () => {
@@ -392,6 +432,7 @@ describe('FriendsService', () => {
],
},
});
expect(mockEventEmitter.emit).toHaveBeenCalled();
});
it('should throw BadRequestException when trying to unfriend self', async () => {

View File

@@ -5,8 +5,16 @@ import {
Logger,
ConflictException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../database/prisma.service';
import { User, FriendRequest, FriendRequestStatus } from '@prisma/client';
import {
FriendEvents,
FriendRequestReceivedEvent,
FriendRequestAcceptedEvent,
FriendRequestDeniedEvent,
UnfriendedEvent,
} from './events/friend.events';
export type FriendRequestWithRelations = FriendRequest & {
sender: User;
@@ -17,7 +25,10 @@ export type FriendRequestWithRelations = FriendRequest & {
export class FriendsService {
private readonly logger = new Logger(FriendsService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
async sendFriendRequest(
senderId: string,
@@ -27,66 +38,82 @@ export class FriendsService {
throw new BadRequestException('Cannot send friend request to yourself');
}
const receiver = await this.prisma.user.findUnique({
where: { id: receiverId },
});
const friendRequest = await this.prisma.$transaction(async (tx) => {
const receiver = await tx.user.findUnique({
where: { id: receiverId },
});
if (!receiver) {
throw new NotFoundException('User not found');
}
const existingFriendship = await this.areFriends(senderId, receiverId);
if (existingFriendship) {
throw new ConflictException('You are already friends with this user');
}
const existingRequest = await this.prisma.friendRequest.findFirst({
where: {
OR: [
{ senderId, receiverId },
{
senderId: receiverId,
receiverId: senderId,
},
],
},
});
if (existingRequest) {
if (existingRequest.status === FriendRequestStatus.PENDING) {
if (existingRequest.senderId === senderId) {
throw new ConflictException(
'You already sent a friend request to this user',
);
} else {
throw new ConflictException(
'This user already sent you a friend request',
);
}
} else {
// If there's an existing request that is not pending (accepted or denied), delete it so a new one can be created
await this.prisma.friendRequest.delete({
where: { id: existingRequest.id },
});
if (!receiver) {
throw new NotFoundException('User not found');
}
}
const friendRequest = await this.prisma.friendRequest.create({
data: {
senderId,
receiverId,
status: FriendRequestStatus.PENDING,
},
include: {
sender: true,
receiver: true,
},
// Check for existing friendship using the transaction client
const existingFriendship = await tx.friendship.findFirst({
where: {
userId: senderId,
friendId: receiverId,
},
});
if (existingFriendship) {
throw new ConflictException('You are already friends with this user');
}
const existingRequest = await tx.friendRequest.findFirst({
where: {
OR: [
{ senderId, receiverId },
{
senderId: receiverId,
receiverId: senderId,
},
],
},
});
if (existingRequest) {
if (existingRequest.status === FriendRequestStatus.PENDING) {
if (existingRequest.senderId === senderId) {
throw new ConflictException(
'You already sent a friend request to this user',
);
} else {
throw new ConflictException(
'This user already sent you a friend request',
);
}
} else {
// If there's an existing request that is not pending (accepted or denied), delete it so a new one can be created
await tx.friendRequest.delete({
where: { id: existingRequest.id },
});
}
}
return await tx.friendRequest.create({
data: {
senderId,
receiverId,
status: FriendRequestStatus.PENDING,
},
include: {
sender: true,
receiver: true,
},
});
});
this.logger.log(
`Friend request sent from ${senderId} to ${receiverId} (ID: ${friendRequest.id})`,
);
// Emit event
const event: FriendRequestReceivedEvent = {
userId: receiverId,
friendRequest,
};
this.eventEmitter.emit(FriendEvents.REQUEST_RECEIVED, event);
return friendRequest;
}
@@ -183,6 +210,13 @@ export class FriendsService {
`Friend request ${requestId} accepted. Users ${friendRequest.senderId} and ${friendRequest.receiverId} are now friends`,
);
// Emit event
const event: FriendRequestAcceptedEvent = {
userId: friendRequest.senderId,
friendRequest: result,
};
this.eventEmitter.emit(FriendEvents.REQUEST_ACCEPTED, event);
return result;
}
@@ -227,6 +261,13 @@ export class FriendsService {
this.logger.log(`Friend request ${requestId} denied by user ${userId}`);
// Emit event
const event: FriendRequestDeniedEvent = {
userId: friendRequest.senderId,
friendRequest: result,
};
this.eventEmitter.emit(FriendEvents.REQUEST_DENIED, event);
return result;
}
@@ -268,6 +309,13 @@ export class FriendsService {
});
this.logger.log(`User ${userId} unfriended user ${friendId}`);
// Emit event
const event: UnfriendedEvent = {
userId: friendId,
friendId: userId,
};
this.eventEmitter.emit(FriendEvents.UNFRIENDED, event);
}
async areFriends(userId: string, friendId: string): Promise<boolean> {