friends system
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FriendRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DENIED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "friend_requests" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sender_id" TEXT NOT NULL,
|
||||||
|
"receiver_id" TEXT NOT NULL,
|
||||||
|
"status" "FriendRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "friend_requests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "friendships" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"friend_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "friendships_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "friend_requests_sender_id_receiver_id_key" ON "friend_requests"("sender_id", "receiver_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "friendships_user_id_friend_id_key" ON "friendships"("user_id", "friend_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "friend_requests" ADD CONSTRAINT "friend_requests_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "friend_requests" ADD CONSTRAINT "friend_requests_receiver_id_fkey" FOREIGN KEY ("receiver_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "friendships" ADD CONSTRAINT "friendships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "friendships" ADD CONSTRAINT "friendships_friend_id_fkey" FOREIGN KEY ("friend_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/client"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -43,5 +42,44 @@ model User {
|
|||||||
/// Timestamp of last login
|
/// Timestamp of last login
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
|
||||||
|
sentFriendRequests FriendRequest[] @relation("SentFriendRequests")
|
||||||
|
receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests")
|
||||||
|
userFriendships Friendship[] @relation("UserFriendships")
|
||||||
|
friendFriendships Friendship[] @relation("FriendFriendships")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FriendRequest {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
senderId String @map("sender_id")
|
||||||
|
receiverId String @map("receiver_id")
|
||||||
|
status FriendRequestStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
sender User @relation("SentFriendRequests", fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
receiver User @relation("ReceivedFriendRequests", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([senderId, receiverId])
|
||||||
|
@@map("friend_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Friendship {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
friendId String @map("friend_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation("UserFriendships", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
friend User @relation("FriendFriendships", fields: [friendId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, friendId])
|
||||||
|
@@map("friendships")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FriendRequestStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
DENIED
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { WsModule } from './ws/ws.module';
|
import { WsModule } from './ws/ws.module';
|
||||||
|
import { FriendsModule } from './friends/friends.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates required environment variables.
|
* Validates required environment variables.
|
||||||
@@ -43,16 +44,16 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Configure global environment variables with validation
|
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true, // Make ConfigService available throughout the app
|
isGlobal: true,
|
||||||
envFilePath: '.env', // Load from .env file
|
envFilePath: '.env',
|
||||||
validate: validateEnvironment, // Validate required environment variables
|
validate: validateEnvironment,
|
||||||
}),
|
}),
|
||||||
DatabaseModule, // Global database module for Prisma
|
DatabaseModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
WsModule,
|
WsModule,
|
||||||
|
FriendsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
88
src/friends/dto/friend-response.dto.ts
Normal file
88
src/friends/dto/friend-response.dto.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UserBasicDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'User unique identifier',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's display name",
|
||||||
|
example: 'John Doe',
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's username",
|
||||||
|
example: 'johndoe',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "User's profile picture URL",
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
picture?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FriendRequestResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friend request unique identifier',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Sender information',
|
||||||
|
type: UserBasicDto,
|
||||||
|
})
|
||||||
|
sender: UserBasicDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Receiver information',
|
||||||
|
type: UserBasicDto,
|
||||||
|
})
|
||||||
|
receiver: UserBasicDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friend request status',
|
||||||
|
enum: ['PENDING', 'ACCEPTED', 'DENIED'],
|
||||||
|
example: 'PENDING',
|
||||||
|
})
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friend request creation timestamp',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friend request last update timestamp',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FriendshipResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friendship unique identifier',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friend information',
|
||||||
|
type: UserBasicDto,
|
||||||
|
})
|
||||||
|
friend: UserBasicDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Friendship creation timestamp',
|
||||||
|
example: '2024-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
13
src/friends/dto/search-users.dto.ts
Normal file
13
src/friends/dto/search-users.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchUsersDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Username to search for (partial match)',
|
||||||
|
example: 'john',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
13
src/friends/dto/send-friend-request.dto.ts
Normal file
13
src/friends/dto/send-friend-request.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendFriendRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'User ID to send friend request to',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@IsUUID()
|
||||||
|
receiverId: string;
|
||||||
|
}
|
||||||
298
src/friends/friends.controller.spec.ts
Normal file
298
src/friends/friends.controller.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
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';
|
||||||
|
|
||||||
|
enum FriendRequestStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
DENIED = 'DENIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FriendsController', () => {
|
||||||
|
let controller: FriendsController;
|
||||||
|
|
||||||
|
const mockAuthUser = {
|
||||||
|
keycloakSub: 'f:realm:user1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUser1 = {
|
||||||
|
id: 'user-1',
|
||||||
|
keycloakSub: 'f:realm:user1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user1',
|
||||||
|
picture: null,
|
||||||
|
roles: [],
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUser2 = {
|
||||||
|
id: 'user-2',
|
||||||
|
keycloakSub: 'f:realm:user2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
name: 'User Two',
|
||||||
|
username: 'user2',
|
||||||
|
picture: null,
|
||||||
|
roles: [],
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFriendRequest = {
|
||||||
|
id: 'request-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
receiverId: 'user-2',
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
sender: mockUser1,
|
||||||
|
receiver: mockUser2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFriendship = {
|
||||||
|
id: 'friendship-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
friendId: 'user-2',
|
||||||
|
createdAt: new Date(),
|
||||||
|
friend: mockUser2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFriendsService = {
|
||||||
|
sendFriendRequest: jest.fn(),
|
||||||
|
getPendingReceivedRequests: jest.fn(),
|
||||||
|
getPendingSentRequests: jest.fn(),
|
||||||
|
acceptFriendRequest: jest.fn(),
|
||||||
|
denyFriendRequest: jest.fn(),
|
||||||
|
getFriends: jest.fn(),
|
||||||
|
unfriend: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
searchUsers: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthService = {
|
||||||
|
syncUserFromToken: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStateGateway = {
|
||||||
|
emitFriendRequestReceived: jest.fn(),
|
||||||
|
emitFriendRequestAccepted: jest.fn(),
|
||||||
|
emitFriendRequestDenied: jest.fn(),
|
||||||
|
emitUnfriended: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [FriendsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: FriendsService, useValue: mockFriendsService },
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: StateGateway, useValue: mockStateGateway },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<FriendsController>(FriendsController);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockAuthService.syncUserFromToken.mockResolvedValue(mockUser1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchUsers', () => {
|
||||||
|
it('should return list of users matching search', async () => {
|
||||||
|
const users = [mockUser2];
|
||||||
|
mockUsersService.searchUsers.mockResolvedValue(users);
|
||||||
|
|
||||||
|
const result = await controller.searchUsers(
|
||||||
|
{ username: 'user2' },
|
||||||
|
mockAuthUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: mockUser2.id,
|
||||||
|
name: mockUser2.name,
|
||||||
|
username: mockUser2.username,
|
||||||
|
picture: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mockUsersService.searchUsers).toHaveBeenCalledWith(
|
||||||
|
'user2',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendFriendRequest', () => {
|
||||||
|
it('should send friend request and emit WebSocket event', async () => {
|
||||||
|
mockFriendsService.sendFriendRequest.mockResolvedValue(mockFriendRequest);
|
||||||
|
|
||||||
|
const result = await controller.sendFriendRequest(
|
||||||
|
{ receiverId: 'user-2' },
|
||||||
|
mockAuthUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: mockFriendRequest.id,
|
||||||
|
sender: {
|
||||||
|
id: mockUser1.id,
|
||||||
|
name: mockUser1.name,
|
||||||
|
username: mockUser1.username,
|
||||||
|
picture: undefined,
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
id: mockUser2.id,
|
||||||
|
name: mockUser2.name,
|
||||||
|
username: mockUser2.username,
|
||||||
|
picture: undefined,
|
||||||
|
},
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
createdAt: mockFriendRequest.createdAt,
|
||||||
|
updatedAt: mockFriendRequest.updatedAt,
|
||||||
|
});
|
||||||
|
expect(mockFriendsService.sendFriendRequest).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'user-2',
|
||||||
|
);
|
||||||
|
expect(mockStateGateway.emitFriendRequestReceived).toHaveBeenCalledWith(
|
||||||
|
'user-2',
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReceivedRequests', () => {
|
||||||
|
it('should return list of received friend requests', async () => {
|
||||||
|
mockFriendsService.getPendingReceivedRequests.mockResolvedValue([
|
||||||
|
mockFriendRequest,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await controller.getReceivedRequests(mockAuthUser);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe(mockFriendRequest.id);
|
||||||
|
expect(
|
||||||
|
mockFriendsService.getPendingReceivedRequests,
|
||||||
|
).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSentRequests', () => {
|
||||||
|
it('should return list of sent friend requests', async () => {
|
||||||
|
mockFriendsService.getPendingSentRequests.mockResolvedValue([
|
||||||
|
mockFriendRequest,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await controller.getSentRequests(mockAuthUser);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe(mockFriendRequest.id);
|
||||||
|
expect(mockFriendsService.getPendingSentRequests).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acceptFriendRequest', () => {
|
||||||
|
it('should accept friend request and emit WebSocket event', async () => {
|
||||||
|
const acceptedRequest = {
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.ACCEPTED,
|
||||||
|
};
|
||||||
|
mockFriendsService.acceptFriendRequest.mockResolvedValue(acceptedRequest);
|
||||||
|
|
||||||
|
const result = await controller.acceptFriendRequest(
|
||||||
|
'request-1',
|
||||||
|
mockAuthUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(FriendRequestStatus.ACCEPTED);
|
||||||
|
expect(mockFriendsService.acceptFriendRequest).toHaveBeenCalledWith(
|
||||||
|
'request-1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(mockStateGateway.emitFriendRequestAccepted).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
acceptedRequest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('denyFriendRequest', () => {
|
||||||
|
it('should deny friend request and emit WebSocket event', async () => {
|
||||||
|
const deniedRequest = {
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.DENIED,
|
||||||
|
};
|
||||||
|
mockFriendsService.denyFriendRequest.mockResolvedValue(deniedRequest);
|
||||||
|
|
||||||
|
const result = await controller.denyFriendRequest(
|
||||||
|
'request-1',
|
||||||
|
mockAuthUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(FriendRequestStatus.DENIED);
|
||||||
|
expect(mockFriendsService.denyFriendRequest).toHaveBeenCalledWith(
|
||||||
|
'request-1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(mockStateGateway.emitFriendRequestDenied).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
deniedRequest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFriends', () => {
|
||||||
|
it('should return list of friends', async () => {
|
||||||
|
mockFriendsService.getFriends.mockResolvedValue([mockFriendship]);
|
||||||
|
|
||||||
|
const result = await controller.getFriends(mockAuthUser);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: mockFriendship.id,
|
||||||
|
friend: {
|
||||||
|
id: mockUser2.id,
|
||||||
|
name: mockUser2.name,
|
||||||
|
username: mockUser2.username,
|
||||||
|
picture: undefined,
|
||||||
|
},
|
||||||
|
createdAt: mockFriendship.createdAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mockFriendsService.getFriends).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unfriend', () => {
|
||||||
|
it('should unfriend user and emit WebSocket event', async () => {
|
||||||
|
mockFriendsService.unfriend.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await controller.unfriend('user-2', mockAuthUser);
|
||||||
|
|
||||||
|
expect(mockFriendsService.unfriend).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'user-2',
|
||||||
|
);
|
||||||
|
expect(mockStateGateway.emitUnfriended).toHaveBeenCalledWith(
|
||||||
|
'user-2',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
389
src/friends/friends.controller.ts
Normal file
389
src/friends/friends.controller.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
UseGuards,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiParam,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { User, FriendRequest } from '@prisma/client';
|
||||||
|
import { FriendsService } from './friends.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
type AuthenticatedUser,
|
||||||
|
} from '../auth/decorators/current-user.decorator';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { SendFriendRequestDto } from './dto/send-friend-request.dto';
|
||||||
|
import {
|
||||||
|
FriendRequestResponseDto,
|
||||||
|
FriendshipResponseDto,
|
||||||
|
UserBasicDto,
|
||||||
|
} from './dto/friend-response.dto';
|
||||||
|
import { SearchUsersDto } from './dto/search-users.dto';
|
||||||
|
|
||||||
|
type FriendRequestWithRelations = FriendRequest & {
|
||||||
|
sender: User;
|
||||||
|
receiver: User;
|
||||||
|
};
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { StateGateway } from '../ws/state/state.gateway';
|
||||||
|
|
||||||
|
@ApiTags('friends')
|
||||||
|
@Controller('friends')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class FriendsController {
|
||||||
|
private readonly logger = new Logger(FriendsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly friendsService: FriendsService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly stateGateway: StateGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search users by username',
|
||||||
|
description: 'Search for users by username to send friend requests',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'username',
|
||||||
|
required: false,
|
||||||
|
description: 'Username to search for (partial match)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of users matching search criteria',
|
||||||
|
type: [UserBasicDto],
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async searchUsers(
|
||||||
|
@Query() searchDto: SearchUsersDto,
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<UserBasicDto[]> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Searching users with username: ${searchDto.username || 'all'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = await this.usersService.searchUsers(
|
||||||
|
searchDto.username,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return users.map((u: User) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
username: u.username ?? undefined,
|
||||||
|
picture: u.picture ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('requests')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Send a friend request',
|
||||||
|
description: 'Send a friend request to another user',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Friend request sent successfully',
|
||||||
|
type: FriendRequestResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Invalid request (self-request, already friends, etc.)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'User not found',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: 'Friend request already exists',
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async sendFriendRequest(
|
||||||
|
@Body() sendRequestDto: SendFriendRequestDto,
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendRequestResponseDto> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`User ${user.id} sending friend request to ${sendRequestDto.receiverId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const friendRequest = await this.friendsService.sendFriendRequest(
|
||||||
|
user.id,
|
||||||
|
sendRequestDto.receiverId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stateGateway.emitFriendRequestReceived(
|
||||||
|
sendRequestDto.receiverId,
|
||||||
|
friendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('requests/received')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get received friend requests',
|
||||||
|
description: 'Get all pending friend requests received by the current user',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of received friend requests',
|
||||||
|
type: [FriendRequestResponseDto],
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async getReceivedRequests(
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendRequestResponseDto[]> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.debug(`Getting received friend requests for user ${user.id}`);
|
||||||
|
|
||||||
|
const requests = await this.friendsService.getPendingReceivedRequests(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return requests.map((req) => this.mapFriendRequestToDto(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('requests/sent')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get sent friend requests',
|
||||||
|
description: 'Get all pending friend requests sent by the current user',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of sent friend requests',
|
||||||
|
type: [FriendRequestResponseDto],
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async getSentRequests(
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendRequestResponseDto[]> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.debug(`Getting sent friend requests for user ${user.id}`);
|
||||||
|
|
||||||
|
const requests = await this.friendsService.getPendingSentRequests(user.id);
|
||||||
|
|
||||||
|
return requests.map((req) => this.mapFriendRequestToDto(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('requests/:id/accept')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Accept a friend request',
|
||||||
|
description: 'Accept a pending friend request',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: 'Friend request ID',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Friend request accepted successfully',
|
||||||
|
type: FriendRequestResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Invalid request (not receiver, already processed, etc.)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'Friend request not found',
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async acceptFriendRequest(
|
||||||
|
@Param('id') requestId: string,
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendRequestResponseDto> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.id} accepting friend request ${requestId}`);
|
||||||
|
|
||||||
|
const friendRequest = await this.friendsService.acceptFriendRequest(
|
||||||
|
requestId,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stateGateway.emitFriendRequestAccepted(
|
||||||
|
friendRequest.senderId,
|
||||||
|
friendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('requests/:id/deny')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Deny a friend request',
|
||||||
|
description: 'Deny a pending friend request',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: 'Friend request ID',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Friend request denied successfully',
|
||||||
|
type: FriendRequestResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Invalid request (not receiver, already processed, etc.)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'Friend request not found',
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async denyFriendRequest(
|
||||||
|
@Param('id') requestId: string,
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendRequestResponseDto> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.id} denying friend request ${requestId}`);
|
||||||
|
|
||||||
|
const friendRequest = await this.friendsService.denyFriendRequest(
|
||||||
|
requestId,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stateGateway.emitFriendRequestDenied(
|
||||||
|
friendRequest.senderId,
|
||||||
|
friendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get friends list',
|
||||||
|
description: 'Get all friends of the current user',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of friends',
|
||||||
|
type: [FriendshipResponseDto],
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async getFriends(
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<FriendshipResponseDto[]> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.debug(`Getting friends list for user ${user.id}`);
|
||||||
|
|
||||||
|
const friendships = await this.friendsService.getFriends(user.id);
|
||||||
|
|
||||||
|
return friendships.map((friendship) => ({
|
||||||
|
id: friendship.id,
|
||||||
|
friend: {
|
||||||
|
id: friendship.friend.id,
|
||||||
|
name: friendship.friend.name,
|
||||||
|
username: friendship.friend.username ?? undefined,
|
||||||
|
picture: friendship.friend.picture ?? undefined,
|
||||||
|
},
|
||||||
|
createdAt: friendship.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':friendId')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Unfriend a user',
|
||||||
|
description: 'Remove a user from your friends list',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'friendId',
|
||||||
|
description: 'Friend user ID',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 204,
|
||||||
|
description: 'Successfully unfriended',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Invalid request (cannot unfriend yourself)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: 'Friend not found',
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'Invalid or missing JWT token',
|
||||||
|
})
|
||||||
|
async unfriend(
|
||||||
|
@Param('friendId') friendId: string,
|
||||||
|
@CurrentUser() authUser: AuthenticatedUser,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.authService.syncUserFromToken(authUser);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.id} unfriending user ${friendId}`);
|
||||||
|
|
||||||
|
await this.friendsService.unfriend(user.id, friendId);
|
||||||
|
|
||||||
|
this.stateGateway.emitUnfriended(friendId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapFriendRequestToDto(
|
||||||
|
friendRequest: FriendRequestWithRelations,
|
||||||
|
): FriendRequestResponseDto {
|
||||||
|
return {
|
||||||
|
id: friendRequest.id,
|
||||||
|
sender: {
|
||||||
|
id: friendRequest.sender.id,
|
||||||
|
name: friendRequest.sender.name,
|
||||||
|
username: friendRequest.sender.username ?? undefined,
|
||||||
|
picture: friendRequest.sender.picture ?? undefined,
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
id: friendRequest.receiver.id,
|
||||||
|
name: friendRequest.receiver.name,
|
||||||
|
username: friendRequest.receiver.username ?? undefined,
|
||||||
|
picture: friendRequest.receiver.picture ?? undefined,
|
||||||
|
},
|
||||||
|
status: friendRequest.status,
|
||||||
|
createdAt: friendRequest.createdAt,
|
||||||
|
updatedAt: friendRequest.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/friends/friends.module.ts
Normal file
15
src/friends/friends.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FriendsController } from './friends.controller';
|
||||||
|
import { FriendsService } from './friends.service';
|
||||||
|
import { DatabaseModule } from '../database/database.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { WsModule } from '../ws/ws.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, AuthModule, UsersModule, WsModule],
|
||||||
|
controllers: [FriendsController],
|
||||||
|
providers: [FriendsService],
|
||||||
|
exports: [FriendsService],
|
||||||
|
})
|
||||||
|
export class FriendsModule {}
|
||||||
435
src/friends/friends.service.spec.ts
Normal file
435
src/friends/friends.service.spec.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { FriendsService } from './friends.service';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
enum FriendRequestStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
DENIED = 'DENIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FriendsService', () => {
|
||||||
|
let service: FriendsService;
|
||||||
|
|
||||||
|
const mockUser1 = {
|
||||||
|
id: 'user-1',
|
||||||
|
keycloakSub: 'f:realm:user1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user1',
|
||||||
|
picture: null,
|
||||||
|
roles: [],
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUser2 = {
|
||||||
|
id: 'user-2',
|
||||||
|
keycloakSub: 'f:realm:user2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
name: 'User Two',
|
||||||
|
username: 'user2',
|
||||||
|
picture: null,
|
||||||
|
roles: [],
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFriendRequest = {
|
||||||
|
id: 'request-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
receiverId: 'user-2',
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
sender: mockUser1,
|
||||||
|
receiver: mockUser2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFriendship = {
|
||||||
|
id: 'friendship-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
friendId: 'user-2',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
friendRequest: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
friendship: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
},
|
||||||
|
$transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FriendsService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<FriendsService>(FriendsService);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendFriendRequest', () => {
|
||||||
|
it('should send a friend request successfully', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrismaService.friendRequest.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrismaService.friendRequest.create.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendFriendRequest('user-1', 'user-2');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockFriendRequest);
|
||||||
|
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'user-2' },
|
||||||
|
});
|
||||||
|
expect(mockPrismaService.friendRequest.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
senderId: 'user-1',
|
||||||
|
receiverId: 'user-2',
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when trying to send request to self', async () => {
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-1'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-1'),
|
||||||
|
).rejects.toThrow('Cannot send friend request to yourself');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when receiver does not exist', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'nonexistent'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'nonexistent'),
|
||||||
|
).rejects.toThrow('User not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException when users are already friends', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow('You are already friends with this user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException when request already exists', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrismaService.friendRequest.findFirst.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow('You already sent a friend request to this user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException when reverse request exists', async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser1);
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrismaService.friendRequest.findFirst.mockResolvedValue({
|
||||||
|
...mockFriendRequest,
|
||||||
|
senderId: 'user-2',
|
||||||
|
receiverId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
|
).rejects.toThrow('This user already sent you a friend request');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPendingReceivedRequests', () => {
|
||||||
|
it('should return pending received requests', async () => {
|
||||||
|
const requests = [mockFriendRequest];
|
||||||
|
mockPrismaService.friendRequest.findMany.mockResolvedValue(requests);
|
||||||
|
|
||||||
|
const result = await service.getPendingReceivedRequests('user-2');
|
||||||
|
|
||||||
|
expect(result).toEqual(requests);
|
||||||
|
expect(mockPrismaService.friendRequest.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
receiverId: 'user-2',
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPendingSentRequests', () => {
|
||||||
|
it('should return pending sent requests', async () => {
|
||||||
|
const requests = [mockFriendRequest];
|
||||||
|
mockPrismaService.friendRequest.findMany.mockResolvedValue(requests);
|
||||||
|
|
||||||
|
const result = await service.getPendingSentRequests('user-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(requests);
|
||||||
|
expect(mockPrismaService.friendRequest.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
senderId: 'user-1',
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acceptFriendRequest', () => {
|
||||||
|
it('should accept a friend request and create friendship', async () => {
|
||||||
|
const acceptedRequest = {
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.ACCEPTED,
|
||||||
|
};
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
mockPrismaService.$transaction.mockResolvedValue([acceptedRequest]);
|
||||||
|
|
||||||
|
const result = await service.acceptFriendRequest('request-1', 'user-2');
|
||||||
|
|
||||||
|
expect(result).toEqual(acceptedRequest);
|
||||||
|
expect(mockPrismaService.$transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when request does not exist', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('nonexistent', 'user-2'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('nonexistent', 'user-2'),
|
||||||
|
).rejects.toThrow('Friend request not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when user is not the receiver', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('request-1', 'user-3'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('request-1', 'user-3'),
|
||||||
|
).rejects.toThrow('You can only accept friend requests sent to you');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when request is already accepted', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue({
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.ACCEPTED,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('request-1', 'user-2'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.acceptFriendRequest('request-1', 'user-2'),
|
||||||
|
).rejects.toThrow('Friend request is already accepted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('denyFriendRequest', () => {
|
||||||
|
it('should deny a friend request', async () => {
|
||||||
|
const deniedRequest = {
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.DENIED,
|
||||||
|
};
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
mockPrismaService.friendRequest.update.mockResolvedValue(deniedRequest);
|
||||||
|
|
||||||
|
const result = await service.denyFriendRequest('request-1', 'user-2');
|
||||||
|
|
||||||
|
expect(result).toEqual(deniedRequest);
|
||||||
|
expect(mockPrismaService.friendRequest.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'request-1' },
|
||||||
|
data: { status: FriendRequestStatus.DENIED },
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when request does not exist', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.denyFriendRequest('nonexistent', 'user-2'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when user is not the receiver', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue(
|
||||||
|
mockFriendRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.denyFriendRequest('request-1', 'user-3'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when request is already denied', async () => {
|
||||||
|
mockPrismaService.friendRequest.findUnique.mockResolvedValue({
|
||||||
|
...mockFriendRequest,
|
||||||
|
status: FriendRequestStatus.DENIED,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.denyFriendRequest('request-1', 'user-2'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFriends', () => {
|
||||||
|
it('should return list of friends', async () => {
|
||||||
|
const friendships = [
|
||||||
|
{
|
||||||
|
...mockFriendship,
|
||||||
|
friend: mockUser2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockPrismaService.friendship.findMany.mockResolvedValue(friendships);
|
||||||
|
|
||||||
|
const result = await service.getFriends('user-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(friendships);
|
||||||
|
expect(mockPrismaService.friendship.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
include: {
|
||||||
|
friend: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unfriend', () => {
|
||||||
|
it('should unfriend a user successfully', async () => {
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
|
||||||
|
mockPrismaService.friendship.deleteMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
|
await service.unfriend('user-1', 'user-2');
|
||||||
|
|
||||||
|
expect(mockPrismaService.friendship.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId: 'user-1', friendId: 'user-2' },
|
||||||
|
{ userId: 'user-2', friendId: 'user-1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when trying to unfriend self', async () => {
|
||||||
|
await expect(service.unfriend('user-1', 'user-1')).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await expect(service.unfriend('user-1', 'user-1')).rejects.toThrow(
|
||||||
|
'Cannot unfriend yourself',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when not friends', async () => {
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.unfriend('user-1', 'user-2')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.unfriend('user-1', 'user-2')).rejects.toThrow(
|
||||||
|
'You are not friends with this user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('areFriends', () => {
|
||||||
|
it('should return true when users are friends', async () => {
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
|
||||||
|
|
||||||
|
const result = await service.areFriends('user-1', 'user-2');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when users are not friends', async () => {
|
||||||
|
mockPrismaService.friendship.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.areFriends('user-1', 'user-2');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
273
src/friends/friends.service.ts
Normal file
273
src/friends/friends.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../database/prisma.service';
|
||||||
|
import { User, FriendRequest, FriendRequestStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export type FriendRequestWithRelations = FriendRequest & {
|
||||||
|
sender: User;
|
||||||
|
receiver: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FriendsService {
|
||||||
|
private readonly logger = new Logger(FriendsService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async sendFriendRequest(
|
||||||
|
senderId: string,
|
||||||
|
receiverId: string,
|
||||||
|
): Promise<FriendRequestWithRelations> {
|
||||||
|
if (senderId === receiverId) {
|
||||||
|
throw new BadRequestException('Cannot send friend request to yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiver = await this.prisma.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, status: FriendRequestStatus.PENDING },
|
||||||
|
{
|
||||||
|
senderId: receiverId,
|
||||||
|
receiverId: senderId,
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendRequest = await this.prisma.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})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return friendRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingReceivedRequests(
|
||||||
|
userId: string,
|
||||||
|
): Promise<FriendRequestWithRelations[]> {
|
||||||
|
return this.prisma.friendRequest.findMany({
|
||||||
|
where: {
|
||||||
|
receiverId: userId,
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingSentRequests(
|
||||||
|
userId: string,
|
||||||
|
): Promise<FriendRequestWithRelations[]> {
|
||||||
|
return this.prisma.friendRequest.findMany({
|
||||||
|
where: {
|
||||||
|
senderId: userId,
|
||||||
|
status: FriendRequestStatus.PENDING,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptFriendRequest(
|
||||||
|
requestId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<FriendRequestWithRelations> {
|
||||||
|
const friendRequest = await this.prisma.friendRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friendRequest) {
|
||||||
|
throw new NotFoundException('Friend request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (friendRequest.receiverId !== userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You can only accept friend requests sent to you',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (friendRequest.status !== FriendRequestStatus.PENDING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Friend request is already ${friendRequest.status.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedRequest] = await this.prisma.$transaction([
|
||||||
|
this.prisma.friendRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: FriendRequestStatus.ACCEPTED },
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.friendship.create({
|
||||||
|
data: {
|
||||||
|
userId: friendRequest.senderId,
|
||||||
|
friendId: friendRequest.receiverId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.friendship.create({
|
||||||
|
data: {
|
||||||
|
userId: friendRequest.receiverId,
|
||||||
|
friendId: friendRequest.senderId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Friend request ${requestId} accepted. Users ${friendRequest.senderId} and ${friendRequest.receiverId} are now friends`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async denyFriendRequest(
|
||||||
|
requestId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<FriendRequestWithRelations> {
|
||||||
|
const friendRequest = await this.prisma.friendRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friendRequest) {
|
||||||
|
throw new NotFoundException('Friend request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (friendRequest.receiverId !== userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You can only deny friend requests sent to you',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (friendRequest.status !== FriendRequestStatus.PENDING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Friend request is already ${friendRequest.status.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRequest = await this.prisma.friendRequest.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: { status: FriendRequestStatus.DENIED },
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
receiver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Friend request ${requestId} denied by user ${userId}`);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(userId: string) {
|
||||||
|
return this.prisma.friendship.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
friend: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unfriend(userId: string, friendId: string): Promise<void> {
|
||||||
|
if (userId === friendId) {
|
||||||
|
throw new BadRequestException('Cannot unfriend yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendship = await this.prisma.friendship.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
friendId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friendship) {
|
||||||
|
throw new NotFoundException('You are not friends with this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.friendship.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId, friendId },
|
||||||
|
{ userId: friendId, friendId: userId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User ${userId} unfriended user ${friendId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async areFriends(userId: string, friendId: string): Promise<boolean> {
|
||||||
|
const friendship = await this.prisma.friendship.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
friendId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!friendship;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/types/prisma.ts
Normal file
27
src/types/prisma.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
|
export enum FriendRequestStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
DENIED = 'DENIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendRequest {
|
||||||
|
id: string;
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
status: FriendRequestStatus;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
sender: User;
|
||||||
|
receiver: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Friendship {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user: User;
|
||||||
|
friend: User;
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { PrismaService } from '../database/prisma.service';
|
|||||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
|
||||||
|
|
||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
let service: UsersService;
|
let service: UsersService;
|
||||||
let prismaService: PrismaService;
|
|
||||||
|
|
||||||
const mockUser: User = {
|
const mockUser: User = {
|
||||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
@@ -26,6 +26,7 @@ describe('UsersService', () => {
|
|||||||
user: {
|
user: {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
@@ -44,7 +45,6 @@ describe('UsersService', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<UsersService>(UsersService);
|
service = module.get<UsersService>(UsersService);
|
||||||
prismaService = module.get<PrismaService>(PrismaService);
|
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -509,10 +509,15 @@ describe('UsersService', () => {
|
|||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPrismaService.user.update.mockRejectedValue({
|
const prismaError = new PrismaClientKnownRequestError(
|
||||||
|
'Record not found',
|
||||||
|
{
|
||||||
code: 'P2025',
|
code: 'P2025',
|
||||||
message: 'Record not found',
|
clientVersion: '5.0.0',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mockPrismaService.user.update.mockRejectedValue(prismaError);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.syncProfileFromToken('nonexistent', profileData),
|
service.syncProfileFromToken('nonexistent', profileData),
|
||||||
@@ -587,4 +592,134 @@ describe('UsersService', () => {
|
|||||||
).rejects.toThrow('Database connection failed');
|
).rejects.toThrow('Database connection failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('searchUsers', () => {
|
||||||
|
const users: User[] = [
|
||||||
|
{ ...mockUser, id: 'user1', username: 'alice' },
|
||||||
|
{ ...mockUser, id: 'user2', username: 'bob' },
|
||||||
|
{ ...mockUser, id: 'user3', username: 'charlie' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should search users by username (case-insensitive, partial match)', async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([users[0]]);
|
||||||
|
|
||||||
|
const result = await service.searchUsers('ALI');
|
||||||
|
|
||||||
|
expect(result).toEqual([users[0]]);
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
username: {
|
||||||
|
contains: 'ALI',
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude specified user from results', async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([users[1], users[2]]);
|
||||||
|
|
||||||
|
const result = await service.searchUsers(undefined, 'user1');
|
||||||
|
|
||||||
|
expect(result).toEqual([users[1], users[2]]);
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
not: 'user1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine username search with user exclusion', async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([users[1]]);
|
||||||
|
|
||||||
|
const result = await service.searchUsers('b', 'user1');
|
||||||
|
|
||||||
|
expect(result).toEqual([users[1]]);
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
username: {
|
||||||
|
contains: 'b',
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
not: 'user1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit results to 20 users', async () => {
|
||||||
|
const manyUsers = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
...mockUser,
|
||||||
|
id: `user${i}`,
|
||||||
|
username: `user${i}`,
|
||||||
|
}));
|
||||||
|
const limitedUsers = manyUsers.slice(0, 20);
|
||||||
|
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue(limitedUsers);
|
||||||
|
|
||||||
|
const result = await service.searchUsers();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(20);
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {},
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all users when no filters provided', async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue(users);
|
||||||
|
|
||||||
|
const result = await service.searchUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual(users);
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {},
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no matches found', async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.searchUsers('nonexistent');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should order results by username ascending', async () => {
|
||||||
|
const unorderedUsers = [users[2], users[0], users[1]];
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue(unorderedUsers);
|
||||||
|
|
||||||
|
await service.searchUsers();
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../database/prisma.service';
|
import { PrismaService } from '../database/prisma.service';
|
||||||
import { User } from '@prisma/client';
|
import { User, Prisma } from '@prisma/client';
|
||||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
|
||||||
|
|
||||||
@@ -288,7 +288,6 @@ export class UsersService {
|
|||||||
async delete(id: string, requestingUserKeycloakSub: string): Promise<void> {
|
async delete(id: string, requestingUserKeycloakSub: string): Promise<void> {
|
||||||
const user = await this.findOne(id);
|
const user = await this.findOne(id);
|
||||||
|
|
||||||
// Verify the user is deleting their own account
|
|
||||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`User ${requestingUserKeycloakSub} attempted to delete user ${id}`,
|
`User ${requestingUserKeycloakSub} attempted to delete user ${id}`,
|
||||||
@@ -304,4 +303,34 @@ export class UsersService {
|
|||||||
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,
|
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchUsers(
|
||||||
|
username?: string,
|
||||||
|
excludeUserId?: string,
|
||||||
|
): Promise<User[]> {
|
||||||
|
const where: Prisma.UserWhereInput = {};
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
where.username = {
|
||||||
|
contains: username,
|
||||||
|
mode: 'insensitive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeUserId) {
|
||||||
|
where.id = {
|
||||||
|
not: excludeUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
take: 20,
|
||||||
|
orderBy: {
|
||||||
|
username: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import type { AuthenticatedSocket } from '../../types/socket';
|
|||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||||
|
import { FriendRequestWithRelations } from '../../friends/friends.service';
|
||||||
|
|
||||||
const WS_EVENT = {
|
const WS_EVENT = {
|
||||||
CURSOR_REPORT_POSITION: 'cursor-report-position',
|
CURSOR_REPORT_POSITION: 'cursor-report-position',
|
||||||
|
FRIEND_REQUEST_RECEIVED: 'friend-request-received',
|
||||||
|
FRIEND_REQUEST_ACCEPTED: 'friend-request-accepted',
|
||||||
|
FRIEND_REQUEST_DENIED: 'friend-request-denied',
|
||||||
|
UNFRIENDED: 'unfriended',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@@ -29,6 +34,7 @@ export class StateGateway
|
|||||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
{
|
{
|
||||||
private readonly logger = new Logger(StateGateway.name);
|
private readonly logger = new Logger(StateGateway.name);
|
||||||
|
private userSocketMap: Map<string, string> = new Map();
|
||||||
|
|
||||||
@WebSocketServer() io: Server;
|
@WebSocketServer() io: Server;
|
||||||
|
|
||||||
@@ -74,7 +80,8 @@ export class StateGateway
|
|||||||
|
|
||||||
this.logger.log(`WebSocket authenticated: ${payload.sub}`);
|
this.logger.log(`WebSocket authenticated: ${payload.sub}`);
|
||||||
|
|
||||||
await this.authService.syncUserFromToken(client.data.user);
|
const user = await this.authService.syncUserFromToken(client.data.user);
|
||||||
|
this.userSocketMap.set(user.id, client.id);
|
||||||
|
|
||||||
const { sockets } = this.io.sockets;
|
const { sockets } = this.io.sockets;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -91,6 +98,16 @@ export class StateGateway
|
|||||||
|
|
||||||
handleDisconnect(client: AuthenticatedSocket) {
|
handleDisconnect(client: AuthenticatedSocket) {
|
||||||
const user = client.data.user;
|
const user = client.data.user;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
for (const [userId, socketId] of this.userSocketMap.entries()) {
|
||||||
|
if (socketId === client.id) {
|
||||||
|
this.userSocketMap.delete(userId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Client id: ${client.id} disconnected (user: ${user?.keycloakSub || 'unknown'})`,
|
`Client id: ${client.id} disconnected (user: ${user?.keycloakSub || 'unknown'})`,
|
||||||
);
|
);
|
||||||
@@ -112,4 +129,80 @@ export class StateGateway
|
|||||||
);
|
);
|
||||||
this.logger.debug(`Payload: ${JSON.stringify(data, null, 0)}`);
|
this.logger.debug(`Payload: ${JSON.stringify(data, null, 0)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitFriendRequestReceived(
|
||||||
|
userId: string,
|
||||||
|
friendRequest: FriendRequestWithRelations,
|
||||||
|
) {
|
||||||
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
if (socketId) {
|
||||||
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, {
|
||||||
|
id: friendRequest.id,
|
||||||
|
sender: {
|
||||||
|
id: friendRequest.sender.id,
|
||||||
|
name: friendRequest.sender.name,
|
||||||
|
username: friendRequest.sender.username,
|
||||||
|
picture: friendRequest.sender.picture,
|
||||||
|
},
|
||||||
|
createdAt: friendRequest.createdAt,
|
||||||
|
});
|
||||||
|
this.logger.debug(
|
||||||
|
`Emitted friend request notification to user ${userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitFriendRequestAccepted(
|
||||||
|
userId: string,
|
||||||
|
friendRequest: FriendRequestWithRelations,
|
||||||
|
) {
|
||||||
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
if (socketId) {
|
||||||
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, {
|
||||||
|
id: friendRequest.id,
|
||||||
|
friend: {
|
||||||
|
id: friendRequest.receiver.id,
|
||||||
|
name: friendRequest.receiver.name,
|
||||||
|
username: friendRequest.receiver.username,
|
||||||
|
picture: friendRequest.receiver.picture,
|
||||||
|
},
|
||||||
|
acceptedAt: friendRequest.updatedAt,
|
||||||
|
});
|
||||||
|
this.logger.debug(
|
||||||
|
`Emitted friend request accepted notification to user ${userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitFriendRequestDenied(
|
||||||
|
userId: string,
|
||||||
|
friendRequest: FriendRequestWithRelations,
|
||||||
|
) {
|
||||||
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
if (socketId) {
|
||||||
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, {
|
||||||
|
id: friendRequest.id,
|
||||||
|
denier: {
|
||||||
|
id: friendRequest.receiver.id,
|
||||||
|
name: friendRequest.receiver.name,
|
||||||
|
username: friendRequest.receiver.username,
|
||||||
|
picture: friendRequest.receiver.picture,
|
||||||
|
},
|
||||||
|
deniedAt: friendRequest.updatedAt,
|
||||||
|
});
|
||||||
|
this.logger.debug(
|
||||||
|
`Emitted friend request denied notification to user ${userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitUnfriended(userId: string, friendId: string) {
|
||||||
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
if (socketId) {
|
||||||
|
this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, {
|
||||||
|
friendId,
|
||||||
|
});
|
||||||
|
this.logger.debug(`Emitted unfriended notification to user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
providers: [StateGateway],
|
providers: [StateGateway],
|
||||||
|
exports: [StateGateway],
|
||||||
})
|
})
|
||||||
export class WsModule {}
|
export class WsModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user