diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index 4bca1861..a5d9521b 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -59,6 +59,47 @@ const GameChannelAPIDocs: APIDocs = { } ] }, + subscriptions: { + description: 'List game channels that the player is subscribed to', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + } + }, + samples: [ + { + title: 'Sample response', + sample: { + channels: [ + { + id: 1, + name: 'general-chat', + owner: null, + totalMessages: 308, + memberCount: 42, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' + }, + { + id: 2, + name: 'trade-chat', + owner: null, + totalMessages: 23439, + memberCount: 124, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' + } + ] + } + } + ] + }, post: { description: 'Create a game channel', params: { @@ -67,7 +108,8 @@ const GameChannelAPIDocs: APIDocs = { }, body: { name: 'The name of the channel', - props: 'An array of @type(Props:prop)' + props: 'An array of @type(Props:prop)', + autoCleanup: 'Whether the channel should be automatically deleted when the owner leaves or the channel is empty (default is false)' } }, samples: [ @@ -78,7 +120,8 @@ const GameChannelAPIDocs: APIDocs = { props: [ { key: 'channelType', value: 'guild' }, { key: 'guildId', value: '5912' } - ] + ], + autoCleanup: true } }, { diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index c152e622..a5276bdc 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -12,6 +12,7 @@ export default class GameChannel { id: number @Required({ + methods: ['POST'], validation: async (val: unknown, req: Request): Promise => { const duplicateName = await (req.ctx.em).getRepository(GameChannel).findOne({ name: val, @@ -38,13 +39,21 @@ export default class GameChannel { @Property() totalMessages: number = 0 - @Required() @Property() autoCleanup: boolean = false @ManyToOne(() => Game) game: Game + @Required({ + methods: [], + validation: async (val: unknown): Promise => [ + { + check: val ? Array.isArray(val) : true, + error: 'Props must be an array' + } + ] + }) @Embedded(() => Prop, { array: true }) props: Prop[] = [] diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index a59a9196..d4c0466a 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -5,9 +5,10 @@ import GameChannel from '../../entities/game-channel' import { EntityManager } from '@mikro-orm/mysql' import sanitiseProps from '../../lib/props/sanitiseProps' import Socket from '../../socket' -import { sendMessage, sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' +import { sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' import GameChannelAPIDocs from '../../docs/game-channel-api.docs' import PlayerAlias from '../../entities/player-alias' +import { uniqWith } from 'lodash' function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { const socket: Socket = req.ctx.wss @@ -16,7 +17,7 @@ function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: } function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { - return channel.owner ? channel.owner.id === alias.id : true + return channel.owner ? channel.owner.id === alias.id : false } @Routes([ @@ -90,13 +91,14 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'post') async post(req: Request): Promise { - const { name, props } = req.body + const { name, props, autoCleanup } = req.body const em: EntityManager = req.ctx.em const channel = new GameChannel(req.ctx.state.game) channel.name = name channel.owner = req.ctx.state.alias channel.members.add(req.ctx.state.alias) + channel.autoCleanup = autoCleanup ?? false if (props) { channel.props = sanitiseProps(props) @@ -104,11 +106,7 @@ export default class GameChannelAPIService extends APIService { await em.persistAndFlush(channel) - const socket: Socket = req.ctx.wss - const conn = socket.findConnections((conn) => conn.playerAliasId === req.ctx.state.alias.id)[0] - if (conn) { - sendMessage(conn, 'v1.channels.player-joined', { channel }) - } + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) return { status: 200, @@ -127,12 +125,11 @@ export default class GameChannelAPIService extends APIService { const channel: GameChannel = req.ctx.state.channel if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) channel.members.add(req.ctx.state.alias) await em.flush() } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) - return { status: 200, body: { @@ -149,7 +146,7 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (channel.autoCleanup && channel.owner.id === req.ctx.state.alias.id) { + if (channel.autoCleanup && (channel.owner.id === req.ctx.state.alias.id || await channel.members.loadCount() === 1)) { await em.removeAndFlush(channel) return { @@ -157,10 +154,15 @@ export default class GameChannelAPIService extends APIService { } } - (await channel.members.load()).remove(req.ctx.state.alias) - await em.flush() + if ((await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + if (channel.owner.id === req.ctx.state.alias.id) { + channel.owner = null + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + channel.members.remove(req.ctx.state.alias) + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + await em.flush() + } return { status: 204 @@ -177,13 +179,8 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (canModifyChannel(channel, req.ctx.state.alias)) { - return { - status: 403, - body: { - error: 'This player is not the owner of the channel' - } - } + if (!canModifyChannel(channel, req.ctx.state.alias)) { + req.ctx.throw(403, 'This player is not the owner of the channel') } if (name) { @@ -191,7 +188,12 @@ export default class GameChannelAPIService extends APIService { } if (props) { - channel.props = sanitiseProps(props) + const mergedProps = uniqWith([ + ...sanitiseProps(props), + ...channel.props + ], (a, b) => a.key === b.key) + + channel.props = sanitiseProps(mergedProps, true) } if (ownerAliasId) { @@ -203,12 +205,7 @@ export default class GameChannelAPIService extends APIService { }) if (!newOwner) { - return { - status: 404, - body: { - error: 'Owner alias not found' - } - } + req.ctx.throw(404, 'New owner not found') } channel.owner = newOwner @@ -232,13 +229,8 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (canModifyChannel(channel, req.ctx.state.alias)) { - return { - status: 403, - body: { - error: 'This player is not the owner of the channel' - } - } + if (!canModifyChannel(channel, req.ctx.state.alias)) { + req.ctx.throw(403, 'This player is not the owner of the channel') } await channel.members.removeAll() diff --git a/src/socket/index.ts b/src/socket/index.ts index f1300169..141f0e83 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -35,6 +35,7 @@ export default class Socket { heartbeat(): void { const interval = setInterval(() => { this.connections.forEach((conn) => { + /* v8 ignore start */ if (!conn.alive) { conn.ws.terminate() return @@ -42,6 +43,7 @@ export default class Socket { conn.alive = false conn.ws.ping() + /* v8 ignore end */ }) }, 30_000) @@ -66,12 +68,14 @@ export default class Socket { }) } + /* v8 ignore start */ handlePong(ws: WebSocket): void { const connection = this.findConnectionBySocket(ws) if (!connection) return connection.alive = true } + /* v8 ignore end */ handleCloseConnection(ws: WebSocket): void { this.connections = this.connections.filter((conn) => conn.ws !== ws) @@ -79,10 +83,12 @@ export default class Socket { findConnectionBySocket(ws: WebSocket): SocketConnection | undefined { const connection = this.connections.find((conn) => conn.ws === ws) + /* v8 ignore start */ if (!connection) { ws.close(3000) return } + /* v8 ignore end */ return connection } diff --git a/tests/services/_api/game-channel-api/delete.test.ts b/tests/services/_api/game-channel-api/delete.test.ts new file mode 100644 index 00000000..18c2ecff --- /dev/null +++ b/tests/services/_api/game-channel-api/delete.test.ts @@ -0,0 +1,125 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import GameChannel from '../../../../src/entities/game-channel' + +describe('Game channel API service - delete', () => { + it('should delete a channel if the scope is valid', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should not delete a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not delete a channel if it does not have an owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not delete a channel if the current alias is not the owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = (await new PlayerFactory([apiKey.game]).one()).aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not delete a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not delete a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .delete('/v1/game-channels/54252') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/index.test.ts b/tests/services/_api/game-channel-api/index.test.ts new file mode 100644 index 00000000..f4cde1d9 --- /dev/null +++ b/tests/services/_api/game-channel-api/index.test.ts @@ -0,0 +1,37 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' + +describe('Game channel API service - index', () => { + it('should return a list of game channels if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + await (global.em).persistAndFlush(channels) + + const res = await request(global.app) + .get('/v1/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + res.body.channels.forEach((item, idx) => { + expect(item.id).toBe(channels[idx].id) + }) + }) + + it('should not return game channels if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + await (global.em).persistAndFlush(channels) + + await request(global.app) + .get('/v1/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(403) + }) +}) diff --git a/tests/services/_api/game-channel-api/join.test.ts b/tests/services/_api/game-channel-api/join.test.ts new file mode 100644 index 00000000..36de850f --- /dev/null +++ b/tests/services/_api/game-channel-api/join.test.ts @@ -0,0 +1,92 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - join', () => { + it('should join a channel if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush([channel, player]) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.id).toBe(channel.id) + expect(res.body.channel.memberCount).toBe(1) + }) + + it('should not join a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush([channel, player]) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should join a channel even if the player is already subscribed to it', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.id).toBe(channel.id) + expect(res.body.channel.memberCount).toBe(1) + }) + + it('should not join a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not join a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post('/v1/game-channels/54252/join') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/leave.test.ts b/tests/services/_api/game-channel-api/leave.test.ts new file mode 100644 index 00000000..7dde4635 --- /dev/null +++ b/tests/services/_api/game-channel-api/leave.test.ts @@ -0,0 +1,165 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import GameChannel from '../../../../src/entities/game-channel' + +describe('Game channel API service - leave', () => { + it('should leave a channel if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + }) + + it('should not leave a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should leave a channel even if the player is not subscribed to it', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + }) + + it('should delete a channel if auto cleanup is enabled the owner leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: true })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + channel.members.add([ + (await new PlayerFactory([apiKey.game]).one()).aliases[0], + (await new PlayerFactory([apiKey.game]).one()).aliases[0] + ]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should delete a channel if auto cleanup is enabled the last player leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: true })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should not delete a channel if auto cleanup is not enabled and the owner leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: false })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + channel.members.add([ + (await new PlayerFactory([apiKey.game]).one()).aliases[0], + (await new PlayerFactory([apiKey.game]).one()).aliases[0] + ]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + const refreshedChannel = await em.getRepository(GameChannel).findOne(channel.id) + expect(refreshedChannel.id).toBe(channel.id) + expect(refreshedChannel.owner).toBe(null) + }) + + it('should not leave a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not leave a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post('/v1/game-channels/54252/leave') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/post.test.ts b/tests/services/_api/game-channel-api/post.test.ts index d8ab2c2d..f849bdf0 100644 --- a/tests/services/_api/game-channel-api/post.test.ts +++ b/tests/services/_api/game-channel-api/post.test.ts @@ -1,23 +1,76 @@ import request from 'supertest' import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import { EntityManager } from '@mikro-orm/mysql' describe('Game channel API service - post', () => { it('should create a game channel if the scope is valid', async () => { - const [, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) - await request(global.app) + const res = await request(global.app) .post('/v1/game-channels') + .send({ name: 'Guild chat' }) .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) .expect(200) + + expect(res.body.channel.name).toBe('Guild chat') + expect(res.body.channel.owner.id).toBe(player.aliases[0].id) + expect(res.body.channel.totalMessages).toBe(0) + expect(res.body.channel.props).toStrictEqual([]) + expect(res.body.channel.memberCount).toBe(1) }) it('should not create a game channel if the scope is not valid', async () => { - const [, token] = await createAPIKeyAndToken([]) + const [apiKey, token] = await createAPIKeyAndToken([]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) await request(global.app) .post('/v1/game-channels') + .send({ name: 'Guild chat' }) .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) .expect(403) }) + + it('should not create a game channel if the alias does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) + + const res = await request(global.app) + .post('/v1/game-channels') + .send({ name: 'Guild chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '324') + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Player not found' }) + }) + + it('should create a game channel with props', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) + + const res = await request(global.app) + .post('/v1/game-channels') + .send({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '213432' } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toStrictEqual([ + { key: 'guildId', value: '213432' } + ]) + }) }) diff --git a/tests/services/_api/game-channel-api/put.test.ts b/tests/services/_api/game-channel-api/put.test.ts new file mode 100644 index 00000000..76025abe --- /dev/null +++ b/tests/services/_api/game-channel-api/put.test.ts @@ -0,0 +1,242 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - put', () => { + it('should update a channel if the scope is valid', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.name).toBe('A very interesting chat') + }) + + it('should not update a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not update a channel if it does not have an owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not update a channel if the current alias is not the owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = (await new PlayerFactory([apiKey.game]).one()).aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should update the props of a channel', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '1234' }, + { key: 'deleteMe', value: 'yes' } + ] + })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: [ + { key: 'guildId', value: '4321' }, + { key: 'deleteMe', value: null } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toStrictEqual([ + { key: 'guildId', value: '4321' } + ]) + }) + + it('should require props to be an array', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '1234' } + ] + })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: { + guildId: '4321' + } + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(400) + + expect(res.body).toStrictEqual({ + errors: { + props: ['Props must be an array'] + } + }) + }) + + it('should update the channel owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + const newOwner = await new PlayerFactory([apiKey.game]).one() + + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0], newOwner.aliases[0]) + + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ ownerAliasId: newOwner.aliases[0].id }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.owner.id).toBe(newOwner.aliases[0].id) + }) + + it('should not update the channel owner if the provided alias does not exist', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ ownerAliasId: 3123124 }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'New owner not found' }) + }) + + it('should not update a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not update a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .put('/v1/game-channels/54252') + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/subscriptions.test.ts b/tests/services/_api/game-channel-api/subscriptions.test.ts new file mode 100644 index 00000000..4c4767d9 --- /dev/null +++ b/tests/services/_api/game-channel-api/subscriptions.test.ts @@ -0,0 +1,62 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - subscriptions', () => { + it('should return a list of game channel subscriptions if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const subscribedChannel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + subscribedChannel.members.add(player.aliases[0]) + + const notSubscribedChannels = await new GameChannelFactory(apiKey.game).many(5) + await (global.em).persistAndFlush([subscribedChannel, ...notSubscribedChannels, player]) + + const res = await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channels.length).toBe(1) + expect(res.body.channels[0].id).toBe(subscribedChannel.id) + }) + + it('should not return game channel subscriptions if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + const player = await new PlayerFactory([apiKey.game]).one() + channels[0].members.add(player.aliases[0]) + await (global.em).persistAndFlush([...channels, player]) + + await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not return game channel subscriptions for an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) +}) diff --git a/tests/services/_api/player-api/merge.test.ts b/tests/services/_api/player-api/merge.test.ts index d032562c..0927f2c0 100644 --- a/tests/services/_api/player-api/merge.test.ts +++ b/tests/services/_api/player-api/merge.test.ts @@ -48,12 +48,13 @@ describe('Player API service - merge', () => { }) it('should merge player2 into player1', async () => { + const em: EntityManager = global.em const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) const player1 = await new PlayerFactory([apiKey.game]).one() const player2 = await new PlayerFactory([apiKey.game]).one() - await (global.em).persistAndFlush([player1, player2]) + await em.persistAndFlush([player1, player2]) const res = await request(global.app) .post('/v1/players/merge') @@ -64,11 +65,11 @@ describe('Player API service - merge', () => { expect(res.body.player.id).toBe(player1.id) const prevId = player2.id - const aliases = await (global.em).getRepository(PlayerAlias).find({ player: prevId }) + const aliases = await em.getRepository(PlayerAlias).find({ player: prevId }) expect(aliases).toHaveLength(0) - global.em.clear() - const mergedPlayer = await (global.em).getRepository(Player).findOne(prevId) + em.clear() + const mergedPlayer = await em.getRepository(Player).findOne(prevId) expect(mergedPlayer).toBeNull() }) diff --git a/tests/services/game-channel/index.test.ts b/tests/services/game-channel/index.test.ts index a023f693..bf68cfda 100644 --- a/tests/services/game-channel/index.test.ts +++ b/tests/services/game-channel/index.test.ts @@ -1,13 +1,114 @@ import request from 'supertest' import createUserAndToken from '../../utils/createUserAndToken' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import { EntityManager } from '@mikro-orm/mysql' +import PlayerAliasFactory from '../../fixtures/PlayerAliasFactory' +import PlayerFactory from '../../fixtures/PlayerFactory' +import GameChannelFactory from '../../fixtures/GameChannelFactory' describe('Game channel service - index', () => { it('should return a list of game channels', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channels = await new GameChannelFactory(game).many(10) + await (global.em).persistAndFlush(channels) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + res.body.channels.forEach((item, idx) => { + expect(item.id).toBe(channels[idx].id) + }) + }) + + it('should not return game channels for a non-existent game', async () => { const [token] = await createUserAndToken() + const res = await request(global.app) + .get('/games/99999/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Game not found' }) + }) + + it('should not return game channels for a game the user has no access to', async () => { + const [, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken() + + await new GameChannelFactory(game).many(10) + await request(global.app) - .get('/game-channels') + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(403) + }) + + it('should paginate results when getting channels', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ organisation }) + + const count = 82 + const channels = await new GameChannelFactory(game).many(count) + await (global.em).persistAndFlush(channels) + + const page = Math.floor(count / 50) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(channels.length % 50) + expect(res.body.count).toBe(channels.length) + expect(res.body.itemsPerPage).toBe(50) + }) + + it('should search by channel name', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channelsWithName = await new GameChannelFactory(game).state(() => ({ name: 'General chat' })).many(3) + const channelsWithoutName = await new GameChannelFactory(game).state(() => ({ name: 'Guild chat' })).many(3) + await (global.em).persistAndFlush([...channelsWithName, ...channelsWithoutName]) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ search: 'General', page: 0 }) .auth(token, { type: 'bearer' }) .expect(200) + + expect(res.body.channels).toHaveLength(channelsWithName.length) + }) + + it('should search by owners', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const player = await new PlayerFactory([game]).one() + const playerAlias = await new PlayerAliasFactory(player).state(async () => ({ player, identifier: 'johnny_the_admin' })).one() + + const channelsWithOwner = await new GameChannelFactory(game).state(() => ({ + owner: playerAlias + })).many(3) + + const channelsWithoutOwner = await new GameChannelFactory(game).many(5) + + await (global.em).persistAndFlush([...channelsWithOwner, ...channelsWithoutOwner]) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ search: 'johnny_the_admin', page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(channelsWithOwner.length) }) })