Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[backend] invite to play #224

Merged
merged 13 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ChatService } from './chat.service';
import { MuteService } from 'src/room/mute/mute.service';
import { CreateMessageDto } from './dto/create-message.dto';
import { MessageEntity } from './entities/message.entity';
import { v4 } from 'uuid';

@WebSocketGateway({
cors: {
Expand Down Expand Up @@ -68,6 +69,58 @@ export class ChatGateway {
);
}

@SubscribeMessage('invite-pong')
async handleInvitePong(
@MessageBody() data: { userId: number },
@ConnectedSocket() client: Socket,
) {
const inviteUser = this.chatService.getUser(client);
const invitedUserWsId = this.chatService.getWsFromUserId(data.userId)?.id;
if (!invitedUserWsId) {
return;
} else {
const blockings = await this.chatService.getUsersBlockedBy(data.userId);
if (blockings.some((user) => user.id === inviteUser.id)) return;
const blocked = await this.chatService.getUsersBlockedBy(inviteUser.id);
if (blocked.some((user) => user.id === data.userId)) return;
this.server
.to(invitedUserWsId)
.emit('invite-pong', { userId: inviteUser.id });
this.chatService.addInvite(inviteUser.id, data.userId);
}
}

@SubscribeMessage('invite-cancel-pong')
handleInviteCancelPong(@ConnectedSocket() client: Socket) {
const inviteUser = this.chatService.getUser(client);
this.chatService.removeInvite(inviteUser.id);
}

@SubscribeMessage('approve-pong')
async handleApprovePong(
@MessageBody() data: { userId: number },
@ConnectedSocket() client: Socket,
) {
const approvedUserWsId = this.chatService.getWsFromUserId(data.userId)?.id;
if (!approvedUserWsId) {
return;
} else {
if (
this.chatService.getInvite(data.userId) !==
this.chatService.getUserId(client)
) {
this.server
.to(client.id)
.emit('error-pong', 'No pending invite found.');
return;
}
const emitData = { roomId: v4() };
this.server.to(client.id).emit('match-pong', emitData);
this.server.to(approvedUserWsId).emit('match-pong', emitData);
this.chatService.removeInvite(data.userId);
}
}

@OnEvent('room.leave', { async: true })
async handleLeave(event: RoomLeftEvent) {
this.server.in(event.roomId.toString()).emit('leave', event);
Expand Down
28 changes: 28 additions & 0 deletions backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ export class ChatService {
// Map<User.id, Socket>
private clients = new Map<User['id'], Socket>();
private users = new Map<Socket['id'], User>();
// key: inviter, value: invitee
private invite = new Map<User['id'], User['id']>();

getUser(client: Socket) {
return this.users.get(client.id);
}

getWsFromUserId(userId: number): Socket | undefined {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ほぼ同じような機能の関数なのでgetUserかgetWsFromUserIdのどちらかにフォーマット合わせた方が良いかも?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

フォーマット?
返り値の型を無くすみたいな話ですか?

return this.clients.get(userId);
}

getUserId(client: Socket) {
const user = this.users.get(client.id);
if (user) {
Expand All @@ -48,16 +54,38 @@ export class ChatService {
if (user) {
this.clients.delete(user.id);
this.users.delete(client.id);
this.removeInvite(user.id);
}
}

addInvite(inviterId: number, inviteeId: number) {
this.invite.set(inviterId, inviteeId);
}

getInvite(inviterId: number) {
return this.invite.get(inviterId);
}

removeInvite(inviterId: number) {
this.invite.delete(inviterId);
}

addUserToRoom(roomId: number, userId: number) {
const client = this.clients.get(userId);
if (client) {
client.join(roomId.toString());
}
}

getUsersBlockedBy(userId: number) {
kotto5 marked this conversation as resolved.
Show resolved Hide resolved
return this.prisma.user
.findUniqueOrThrow({
where: { id: userId },
include: { blocking: true },
})
.then((user) => user.blocking);
}

@OnEvent('room.created', { async: true })
async handleRoomCreatedEvent(event: RoomCreatedEvent) {
await this.addUserToRoom(event.roomId, event.userId);
Expand Down
255 changes: 255 additions & 0 deletions backend/test/chat-gateway.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,4 +1046,259 @@ describe('ChatGateway and ChatController (e2e)', () => {
describe('[joinDM]', () => {
// TODO
});
describe('invite pong game', () => {
type UserAndSocket = {
user: any;
ws: Socket;
};
let userAndSockets: UserAndSocket[];

beforeAll(() => {
const users = [user1, user2, mutedUser1, kickedUser1];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kickedUser1は使っていなさそうです。

userAndSockets = users.map((user) => ({
user,
ws: io('ws://localhost:3000/chat', {
extraHeaders: { cookie: 'token=' + user.accessToken },
}),
}));
});
afterAll(() => {
userAndSockets.map((userAndSocket) => {
userAndSocket.ws.close();
});
});
afterEach(() => {
userAndSockets.map((us) => {
us.ws.disconnect();
us.ws.connect();
});
});
describe('invite a user', () => {
describe('success case', () => {
let invite: UserAndSocket;
let invited: UserAndSocket;
let notInvited: UserAndSocket;

let ctx1: Promise<any>;
const mockCallback = jest.fn();

beforeAll(() => {
invite = userAndSockets[0];
invited = userAndSockets[1];
notInvited = userAndSockets[2];
ctx1 = new Promise<any>((resolve) =>
invited.ws.on('invite-pong', (data) => resolve(data)),
);
notInvited.ws.on('invite-pong', mockCallback);

invite.ws.emit('invite-pong', {
userId: invited.user.id,
});
ctx1.then((data) => {
expect(data).toEqual({
userId: invite.user.id,
});
});
});
it('user who is invited should receive invite message', () => ctx1);
it("user who isn't invited should not receive invite message", () =>
new Promise<void>((resolve) =>
setTimeout(() => {
expect(mockCallback).not.toBeCalled();
resolve();
}, 1000),
));
});
// TODO: block してるuser から invite されるケース
describe('failure case', () => {
let invitee;
let blocked;
let mockCallback: jest.Mock<any, any, any>;

beforeAll(async () => {
mockCallback = jest.fn();
invitee = userAndSockets[0];
blocked = userAndSockets[1];
await app
.blockUser(
invitee.user.id,
blocked.user.id,
invitee.user.accessToken,
)
.expect(200);
invitee.ws.on('invite-pong', mockCallback);
blocked.ws.emit('invite-pong', {
userId: invitee.user.id,
});
});
afterAll(async () => {
await app
.unblockUser(
invitee.user.id,
blocked.user.id,
invitee.user.accessToken,
)
.expect(200);
});
it('user should not receive invite message from blocking user', () =>
new Promise<void>((resolve) =>
setTimeout(async () => {
expect(mockCallback).not.toHaveBeenCalled();
resolve();
}, 500),
));
});
describe('invite -> cancel -> invite', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このテストをするのであれば、cancelを挟まずにinvite -> inviteのケースもあるとより良いと思います。

let invitee;
let inviter;
let mockCallback;

beforeAll(() => {
mockCallback = jest.fn();
invitee = userAndSockets[0];
inviter = userAndSockets[1];

invitee.ws.on('invite-pong', mockCallback);
inviter.ws.emit('invite-pong', {
userId: invitee.user.id,
});
inviter.ws.emit('invite-cancel-pong', {
userId: invitee.user.id,
});
inviter.ws.emit('invite-pong', {
userId: invitee.user.id,
});
});
it('user who is invited should receive invite message once per time', () =>
new Promise<void>((resolve) =>
setTimeout(() => {
expect(mockCallback).toHaveBeenCalledTimes(2);
resolve();
}, 1000),
));
});
});
describe('approve invite', () => {
describe('success case', () => {
let PromiseToMatchByInviter: Promise<any>;
let PromiseToMatchByInvited: Promise<any>;
let roomId;
const mockCallback1 = jest.fn();
beforeAll(() => {
const inviter = userAndSockets[0];
const invitee = userAndSockets[1];
const notInvited1 = userAndSockets[2];

const promiseToInvite = new Promise<any>((resolve) =>
invitee.ws.on('invite-pong', (data) => resolve(data)),
);
PromiseToMatchByInviter = new Promise<any>((resolve) =>
inviter.ws.on('match-pong', (data) => resolve(data)),
);
PromiseToMatchByInvited = new Promise<any>((resolve) =>
invitee.ws.on('match-pong', (data) => resolve(data)),
);

notInvited1.ws.on('invite-pong', mockCallback1);
notInvited1.ws.on('approve-pong', mockCallback1);
notInvited1.ws.on('match-pong', mockCallback1);

inviter.ws.emit('invite-pong', {
userId: invitee.user.id,
});
return promiseToInvite.then((data) => {
invitee.ws.emit('approve-pong', {
userId: data.userId,
});
});
});
it("invite user should receive room's id", () =>
PromiseToMatchByInviter.then((data) => {
expect(data).toHaveProperty('roomId');
roomId = data.roomId;
}));
it("approve user should receive room's id", () =>
PromiseToMatchByInvited.then((data) => {
expect(data).toHaveProperty('roomId');
expect(data.roomId).toEqual(roomId);
}));
it('unrelated user should not receive any messages', () =>
new Promise<void>((resolve) =>
setTimeout(() => {
expect(mockCallback1).not.toBeCalled();
resolve();
}, 1000),
));
});
describe('failure case', () => {
const mockCallback1 = jest.fn();
const mockCallback2 = jest.fn();
let errorCtx: Promise<any>;

beforeAll(() => {
const emitter = userAndSockets[0];
const listener = userAndSockets[1];

emitter.ws.on('match-pong', mockCallback1);
errorCtx = new Promise<any>((resolve) =>
emitter.ws.on('error-pong', (data) => resolve(data)),
);
listener.ws.on('match-pong', mockCallback2);

emitter.ws.emit('approve-pong', {
userId: listener.user.id,
});
});
// TODO: 複数のuser から invite されるケース
it('should receive an error when approving without an existing invite', () =>
errorCtx);
it('user should not receive approve message from not invite user', () =>
new Promise<void>((resolve) =>
setTimeout(() => {
expect(mockCallback1).not.toHaveBeenCalled();
expect(mockCallback2).not.toHaveBeenCalled();
resolve();
}, 1000),
));
});
describe('invite -> cancel -> approve: dose not match', () => {
const mockToMatchByEmitter = jest.fn();
const mockToMatchByListener = jest.fn();

beforeAll(() => {
const emitter = userAndSockets[0];
const listener = userAndSockets[1];

emitter.ws.on('match-pong', mockToMatchByEmitter);

const PromiseToInvite = new Promise<any>((resolve) =>
listener.ws.on('invite-pong', (data) => resolve(data)),
);
listener.ws.on('match-pong', mockToMatchByListener);

emitter.ws.emit('invite-pong', {
userId: listener.user.id,
});
return PromiseToInvite.then((data) => {
emitter.ws.emit('invite-cancel-pong', {
userId: data.userId,
});
setTimeout(() => {
listener.ws.emit('approve-pong', {
userId: data.userId,
});
}, 100);
});
});
it('user should not receive match message from canceled invite user', () =>
new Promise<void>((resolve) =>
setTimeout(() => {
expect(mockToMatchByEmitter).not.toHaveBeenCalled();
expect(mockToMatchByListener).not.toHaveBeenCalled();
resolve();
}, 1000),
));
});
});
});
});