diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7bab24..61b708fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,4 +35,10 @@ This will create a policy, entity and REST API for your new entity. If you want ## Migrations -To create a migration, use `yarn migration:create`. This will create a migration class in the `migrations` folder. You will then need to import that migration class into the `index.ts` in the same folder. +To create a migration, use `yarn migration:create`. This will create a migration class in the `migrations` folder. + +Modify the default name of the file from `Migration[Timestamp].ts` to `[Timestamp][PascalCaseDescriptionOfTheMigration].ts`. + +You should also rename the exported class to be `[PascalCaseDescriptionOfTheMigration]`. + +You will then need to import and add that migration class to the end of the list of migrations inside `index.ts` in the same folder. diff --git a/package.json b/package.json index 0db0838b..6de3d429 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.2.0", + "version": "0.3.0", "description": "", "main": "src/index.ts", "scripts": { diff --git a/src/entities/data-export.ts b/src/entities/data-export.ts index afb3bda7..6a2bf0f4 100644 --- a/src/entities/data-export.ts +++ b/src/entities/data-export.ts @@ -12,7 +12,8 @@ export enum DataExportStatus { export enum DataExportAvailableEntities { EVENTS = 'events', PLAYERS = 'players', - PLAYER_ALIASES = 'playerAliases' + PLAYER_ALIASES = 'playerAliases', + LEADERBOARD_ENTRIES = 'leaderboardEntries' } @Entity() diff --git a/src/entities/leaderboard-entry.ts b/src/entities/leaderboard-entry.ts index d56bcf7e..9c0e07f3 100644 --- a/src/entities/leaderboard-entry.ts +++ b/src/entities/leaderboard-entry.ts @@ -16,6 +16,9 @@ export default class LeaderboardEntry { @ManyToOne(() => PlayerAlias, { cascade: [Cascade.REMOVE], eager: true }) playerAlias: PlayerAlias + @Property({ default: false }) + hidden: boolean + @Property() createdAt: Date = new Date() @@ -36,6 +39,7 @@ export default class LeaderboardEntry { service: this.playerAlias.service, identifier: this.playerAlias.identifier }, + hidden: this.hidden, updatedAt: this.updatedAt } } diff --git a/src/entities/leaderboard.ts b/src/entities/leaderboard.ts index f479660a..76258fd8 100644 --- a/src/entities/leaderboard.ts +++ b/src/entities/leaderboard.ts @@ -47,7 +47,8 @@ export default class Leaderboard { name: this.name, sortMode: this.sortMode, unique: this.unique, - createdAt: this.createdAt + createdAt: this.createdAt, + updatedAt: this.updatedAt } } } diff --git a/src/migrations/Migration20210725211129.ts b/src/migrations/20210725211129InitialMigration.ts similarity index 99% rename from src/migrations/Migration20210725211129.ts rename to src/migrations/20210725211129InitialMigration.ts index 733612e2..82745a6d 100644 --- a/src/migrations/Migration20210725211129.ts +++ b/src/migrations/20210725211129InitialMigration.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations' -export class Migration20210725211129 extends Migration { +export class InitialMigration extends Migration { async up(): Promise { this.addSql('create table `organisation` (`id` int unsigned not null auto_increment primary key, `email` varchar(255) not null, `name` varchar(255) not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB') diff --git a/src/migrations/Migration20210926160859.ts b/src/migrations/20210926160859CreateDataExportsTable.ts similarity index 94% rename from src/migrations/Migration20210926160859.ts rename to src/migrations/20210926160859CreateDataExportsTable.ts index 53107e5e..8ea72464 100644 --- a/src/migrations/Migration20210926160859.ts +++ b/src/migrations/20210926160859CreateDataExportsTable.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations' -export class Migration20210926160859 extends Migration { +export class CreateDataExportsTable extends Migration { async up(): Promise { this.addSql('create table `data_export` (`id` int unsigned not null auto_increment primary key, `created_by_user_id` int(11) unsigned not null, `game_id` int(11) unsigned not null, `entities` text not null, `status` tinyint not null, `failed_at` datetime null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') diff --git a/src/migrations/Migration20211107233610.ts b/src/migrations/20211107233610CreateLeaderboardsTable.ts similarity index 96% rename from src/migrations/Migration20211107233610.ts rename to src/migrations/20211107233610CreateLeaderboardsTable.ts index 35270743..64160e05 100644 --- a/src/migrations/Migration20211107233610.ts +++ b/src/migrations/20211107233610CreateLeaderboardsTable.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations' -export class Migration20211107233610 extends Migration { +export class CreateLeaderboardsTable extends Migration { async up(): Promise { this.addSql('create table `leaderboard` (`id` int unsigned not null auto_increment primary key, `internal_name` varchar(255) not null, `name` varchar(255) not null, `sort_mode` enum(\'desc\', \'asc\') not null, `unique` tinyint(1) not null, `game_id` int(11) unsigned not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') diff --git a/src/migrations/Migration20211205171927.ts b/src/migrations/20211205171927CreateUserTwoFactorAuthTable.ts similarity index 92% rename from src/migrations/Migration20211205171927.ts rename to src/migrations/20211205171927CreateUserTwoFactorAuthTable.ts index 9d4322bf..4429924f 100644 --- a/src/migrations/Migration20211205171927.ts +++ b/src/migrations/20211205171927CreateUserTwoFactorAuthTable.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations' -export class Migration20211205171927 extends Migration { +export class CreateUserTwoFactorAuthTable extends Migration { async up(): Promise { this.addSql('create table `user_two_factor_auth` (`id` int unsigned not null auto_increment primary key, `secret` varchar(255) not null, `enabled` tinyint(1) not null) default character set utf8mb4 engine = InnoDB;') diff --git a/src/migrations/Migration20211209003017.ts b/src/migrations/20211209003017CreateUserRecoveryCodeTable.ts similarity index 91% rename from src/migrations/Migration20211209003017.ts rename to src/migrations/20211209003017CreateUserRecoveryCodeTable.ts index 4f70c49d..befe0b88 100644 --- a/src/migrations/Migration20211209003017.ts +++ b/src/migrations/20211209003017CreateUserRecoveryCodeTable.ts @@ -1,6 +1,6 @@ import { Migration } from '@mikro-orm/migrations' -export class Migration20211209003017 extends Migration { +export class CreateUserRecoveryCodeTable extends Migration { async up(): Promise { this.addSql('create table `user_recovery_code` (`id` int unsigned not null auto_increment primary key, `user_id` int(11) unsigned not null, `code` varchar(255) not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') diff --git a/src/migrations/20211221195514CascadeDeletePlayerAliasEvents.ts b/src/migrations/20211221195514CascadeDeletePlayerAliasEvents.ts new file mode 100644 index 00000000..1a4960f4 --- /dev/null +++ b/src/migrations/20211221195514CascadeDeletePlayerAliasEvents.ts @@ -0,0 +1,12 @@ +import { Migration } from '@mikro-orm/migrations' + +export class CascadeDeletePlayerAliasEvents extends Migration { + + async up(): Promise { + this.addSql('alter table `event` modify `player_alias_id` int(11) unsigned null;') + + this.addSql('alter table `event` drop foreign key `event_player_alias_id_foreign`;') + this.addSql('alter table `event` add constraint `event_player_alias_id_foreign` foreign key (`player_alias_id`) references `player_alias` (`id`) on delete cascade;') + } + +} diff --git a/src/migrations/20211224154919AddLeaderboardEntryHiddenColumn.ts b/src/migrations/20211224154919AddLeaderboardEntryHiddenColumn.ts new file mode 100644 index 00000000..4df4663e --- /dev/null +++ b/src/migrations/20211224154919AddLeaderboardEntryHiddenColumn.ts @@ -0,0 +1,9 @@ +import { Migration } from '@mikro-orm/migrations' + +export class AddLeaderboardEntryHiddenColumn extends Migration { + + async up(): Promise { + this.addSql('alter table `leaderboard_entry` add `hidden` tinyint(1) not null default false;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 3fa53022..44173ea8 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,28 +1,38 @@ -import { Migration20210725211129 } from './Migration20210725211129' -import { Migration20210926160859 } from './Migration20210926160859' -import { Migration20211107233610 } from './Migration20211107233610' -import { Migration20211205171927 } from './Migration20211205171927' -import { Migration20211209003017 } from './Migration20211209003017' +import { InitialMigration } from './20210725211129InitialMigration' +import { CreateDataExportsTable } from './20210926160859CreateDataExportsTable' +import { CreateLeaderboardsTable } from './20211107233610CreateLeaderboardsTable' +import { CreateUserTwoFactorAuthTable } from './20211205171927CreateUserTwoFactorAuthTable' +import { CreateUserRecoveryCodeTable } from './20211209003017CreateUserRecoveryCodeTable' +import { CascadeDeletePlayerAliasEvents } from './20211221195514CascadeDeletePlayerAliasEvents' +import { AddLeaderboardEntryHiddenColumn } from './20211224154919AddLeaderboardEntryHiddenColumn' export default [ { - name: 'Migration20210725211129', - class: Migration20210725211129 + name: 'InitialMigration', + class: InitialMigration }, { - name: 'Migration20210926160859', - class: Migration20210926160859 + name: 'CreateDataExportsTable', + class: CreateDataExportsTable }, { - name: 'Migration20211107233610', - class: Migration20211107233610 + name: 'CreateLeaderboardsTable', + class: CreateLeaderboardsTable }, { - name: 'Migration20211205171927', - class: Migration20211205171927 + name: 'CreateUserTwoFactorAuthTable', + class: CreateUserTwoFactorAuthTable }, { - name: 'Migration20211209003017', - class: Migration20211209003017 + name: 'CreateUserRecoveryCodeTable', + class: CreateUserRecoveryCodeTable + }, + { + name: 'CascadeDeletePlayerAliasEvents', + class: CascadeDeletePlayerAliasEvents + }, + { + name: 'AddLeaderboardEntryHiddenColumn', + class: AddLeaderboardEntryHiddenColumn } ] diff --git a/src/policies/leaderboards.policy.ts b/src/policies/leaderboards.policy.ts index cf738b42..2cd8461c 100644 --- a/src/policies/leaderboards.policy.ts +++ b/src/policies/leaderboards.policy.ts @@ -37,4 +37,22 @@ export default class LeaderboardsPolicy extends Policy { return await this.canAccessGame(gameId) } + + async updateEntry(req: ServiceRequest): Promise { + return await this.get({ + ...req, + query: { + gameId: req.body.gameId + } + }) + } + + async updateLeaderboard(req: ServiceRequest): Promise { + return await this.get({ + ...req, + query: { + gameId: req.body.gameId + } + }) + } } diff --git a/src/services/data-exports.service.ts b/src/services/data-exports.service.ts index ea01fb44..d906d87b 100644 --- a/src/services/data-exports.service.ts +++ b/src/services/data-exports.service.ts @@ -14,6 +14,7 @@ import ormConfig from '../config/mikro-orm.config' import { EmailConfig } from '../lib/messaging/sendEmail' import { unlink } from 'fs/promises' import dataExportReady from '../emails/data-export-ready' +import LeaderboardEntry from '../entities/leaderboard-entry' interface EntityWithProps { props: Prop[] @@ -28,7 +29,7 @@ interface DataExportJob { dataExportId: number } -type ExportableEntity = Event | Player | PlayerAlias +type ExportableEntity = Event | Player | PlayerAlias | LeaderboardEntry type ExportableEntityWithProps = ExportableEntity & EntityWithProps @Routes([ @@ -124,19 +125,26 @@ export default class DataExportsService implements Service { if (dataExport.entities.includes(DataExportAvailableEntities.EVENTS)) { const events = await em.getRepository(Event).find({ game: dataExport.game }, ['playerAlias']) - zip.addFile('events.csv', this.buildCSV(DataExportAvailableEntities.EVENTS, events)) + zip.addFile(`${DataExportAvailableEntities.EVENTS}.csv`, this.buildCSV(DataExportAvailableEntities.EVENTS, events)) } if (dataExport.entities.includes(DataExportAvailableEntities.PLAYERS)) { const players = await em.getRepository(Player).find({ game: dataExport.game }) - zip.addFile('players.csv', this.buildCSV(DataExportAvailableEntities.PLAYERS, players)) + zip.addFile(`${DataExportAvailableEntities.PLAYERS}.csv`, this.buildCSV(DataExportAvailableEntities.PLAYERS, players)) } if (dataExport.entities.includes(DataExportAvailableEntities.PLAYER_ALIASES)) { const aliases = await em.getRepository(PlayerAlias).find({ player: { game: dataExport.game } }) - zip.addFile('player-aliases.csv', this.buildCSV(DataExportAvailableEntities.PLAYER_ALIASES, aliases)) + zip.addFile(`${DataExportAvailableEntities.PLAYER_ALIASES}.csv`, this.buildCSV(DataExportAvailableEntities.PLAYER_ALIASES, aliases)) + } + + if (dataExport.entities.includes(DataExportAvailableEntities.LEADERBOARD_ENTRIES)) { + const entries = await em.getRepository(LeaderboardEntry).find({ + leaderboard: { game: dataExport.game } + }, ['leaderboard']) + zip.addFile(`${DataExportAvailableEntities.LEADERBOARD_ENTRIES}.csv`, this.buildCSV(DataExportAvailableEntities.LEADERBOARD_ENTRIES, entries)) } return zip @@ -150,6 +158,8 @@ export default class DataExportsService implements Service { return ['id', 'lastSeenAt', 'createdAt', 'updatedAt', 'props'] case DataExportAvailableEntities.PLAYER_ALIASES: return ['id', 'service', 'identifier', 'player.id', 'createdAt', 'updatedAt'] + case DataExportAvailableEntities.LEADERBOARD_ENTRIES: + return ['id', 'score', 'leaderboard.id', 'leaderboard.internalName', 'playerAlias.id', 'playerAlias.service', 'playerAlias.identifier', 'playerAlias.player.id', 'createdAt', 'updatedAt'] } } diff --git a/src/services/leaderboards.service.ts b/src/services/leaderboards.service.ts index 7cb20c83..cbfce558 100644 --- a/src/services/leaderboards.service.ts +++ b/src/services/leaderboards.service.ts @@ -21,6 +21,16 @@ import LeaderboardsPolicy from '../policies/leaderboards.policy' method: 'GET', path: '/:internalName/entries', handler: 'entries' + }, + { + method: 'PATCH', + path: '/:internalName/entries/:id', + handler: 'updateEntry' + }, + { + method: 'PATCH', + path: '/:internalName', + handler: 'updateLeaderboard' } ]) export default class LeaderboardsService implements Service { @@ -112,8 +122,11 @@ export default class LeaderboardsService implements Service { .where({ leaderboard }) if (aliasId) { - baseQuery = baseQuery - .where({ playerAlias: Number(aliasId) }) + baseQuery = baseQuery.andWhere({ playerAlias: Number(aliasId) }) + } + + if (req.ctx.state.user.api === true) { + baseQuery = baseQuery.andWhere({ hidden: false }) } const { count } = await baseQuery @@ -135,4 +148,57 @@ export default class LeaderboardsService implements Service { } } } + + @Validate({ + body: ['gameId'] + }) + @HasPermission(LeaderboardsPolicy, 'updateEntry') + async updateEntry(req: ServiceRequest): Promise { + const { id } = req.params + const em: EntityManager = req.ctx.em + + const entry = await em.getRepository(LeaderboardEntry).findOne(Number(id)) + if (!entry) { + req.ctx.throw(404, 'Leaderboard entry not found') + } + + const { hidden } = req.body + + if (typeof hidden === 'boolean') { + entry.hidden = hidden + } + + await em.flush() + + return { + status: 200, + body: { + entry + } + } + } + + @Validate({ + body: ['gameId'] + }) + @HasPermission(LeaderboardsPolicy, 'updateLeaderboard') + async updateLeaderboard(req: ServiceRequest): Promise { + const em: EntityManager = req.ctx.em + + const { name, sortMode, unique } = req.body + const leaderboard = req.ctx.state.leaderboard + + if (name) leaderboard.name = name + if (sortMode) leaderboard.sortMode = sortMode + if (typeof unique === 'boolean') leaderboard.unique = unique + + await em.flush() + + return { + status: 200, + body: { + leaderboard + } + } + } } diff --git a/src/services/public/users-public.service.ts b/src/services/public/users-public.service.ts index 6cbca3ab..5cc6d9c4 100644 --- a/src/services/public/users-public.service.ts +++ b/src/services/public/users-public.service.ts @@ -273,7 +273,7 @@ export default class UsersPublicService implements Service { const hasSession = (await redis.get(`2fa:${user.id}`)) === 'true' if (!hasSession) { - req.ctx.throw(403, 'Session expired') + req.ctx.throw(403, { message: 'Session expired', sessionExpired: true }) } if (!authenticator.check(code, user.twoFactorAuth.secret)) { @@ -305,7 +305,7 @@ export default class UsersPublicService implements Service { const hasSession = (await redis.get(`2fa:${user.id}`)) === 'true' if (!hasSession) { - req.ctx.throw(403, 'Session expired') + req.ctx.throw(403, { message: 'Session expired', sessionExpired: true }) } const recoveryCode = user.recoveryCodes.getItems().find((recoveryCode) => { diff --git a/tests/fixtures/LeaderboardEntryFactory.ts b/tests/fixtures/LeaderboardEntryFactory.ts index e5ea93e3..6c32c37f 100644 --- a/tests/fixtures/LeaderboardEntryFactory.ts +++ b/tests/fixtures/LeaderboardEntryFactory.ts @@ -11,6 +11,7 @@ export default class LeaderboardEntryFactory extends Factory { constructor(leaderboard: Leaderboard, availablePlayers: Player[]) { super(LeaderboardEntry, 'base') this.register('base', this.base) + this.register('hidden', this.hidden) this.leaderboard = leaderboard this.availablePlayers = availablePlayers @@ -26,4 +27,10 @@ export default class LeaderboardEntryFactory extends Factory { score: Number(casual.double(10, 100000).toFixed(2)) } } + + protected hidden(): Partial { + return { + hidden: true + } + } } diff --git a/tests/fixtures/LeaderboardFactory.ts b/tests/fixtures/LeaderboardFactory.ts index eae4aaa2..f4b1642e 100644 --- a/tests/fixtures/LeaderboardFactory.ts +++ b/tests/fixtures/LeaderboardFactory.ts @@ -29,7 +29,7 @@ export default class LeaderboardFactory extends Factory { game, internalName: casual.word, name: casual.title, - sortMode: casual.random_element(Object.keys(LeaderboardSortMode)), + sortMode: casual.random_element([LeaderboardSortMode.ASC, LeaderboardSortMode.DESC]), unique: casual.boolean, entries: new Collection(leaderboard, entries) } diff --git a/tests/services/_api/events-api/post.test.ts b/tests/services/_api/events-api/post.test.ts index 01da9f49..2db655c2 100644 --- a/tests/services/_api/events-api/post.test.ts +++ b/tests/services/_api/events-api/post.test.ts @@ -167,7 +167,7 @@ describe('Events API service - post', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Events must be an array') + expect(res.body).toStrictEqual({ message: 'Events must be an array' }) }) it('should sanitise event props into strings', async () => { diff --git a/tests/services/_api/leaderboards-api/get.test.ts b/tests/services/_api/leaderboards-api/get.test.ts index 866369eb..7e81c409 100644 --- a/tests/services/_api/leaderboards-api/get.test.ts +++ b/tests/services/_api/leaderboards-api/get.test.ts @@ -6,11 +6,11 @@ import APIKey, { APIKeyScope } from '../../../../src/entities/api-key' import { createToken } from '../../../../src/services/api-keys.service' import UserFactory from '../../../fixtures/UserFactory' import GameFactory from '../../../fixtures/GameFactory' -import Leaderboard from '../../../../src/entities/leaderboard' import LeaderboardFactory from '../../../fixtures/LeaderboardFactory' import PlayerFactory from '../../../fixtures/PlayerFactory' import Game from '../../../../src/entities/game' import LeaderboardEntryFactory from '../../../fixtures/LeaderboardEntryFactory' +import { LeaderboardSortMode } from '../../../../src/entities/leaderboard' const baseUrl = '/v1/leaderboards' @@ -18,7 +18,6 @@ describe('Leaderboards API service - get', () => { let app: Koa let game: Game - let leaderboard: Leaderboard let apiKey: APIKey let token: string @@ -28,12 +27,11 @@ describe('Leaderboards API service - get', () => { const user = await new UserFactory().one() game = await new GameFactory(user.organisation).one() - leaderboard = await new LeaderboardFactory([game]).one() apiKey = new APIKey(game, user) token = await createToken(apiKey) - await (app.context.em).persistAndFlush([apiKey, leaderboard]) + await (app.context.em).persistAndFlush(apiKey) }) afterAll(async () => { @@ -41,6 +39,8 @@ describe('Leaderboards API service - get', () => { }) it('should get leaderboard entries if the scope is valid', async () => { + const leaderboard = await new LeaderboardFactory([game]).one() + apiKey.scopes = [APIKeyScope.READ_LEADERBOARDS] const players = await new PlayerFactory([game]).many(3) const entries = await new LeaderboardEntryFactory(leaderboard, players).many(5) @@ -57,7 +57,37 @@ describe('Leaderboards API service - get', () => { expect(res.body.entries).toHaveLength(entries.length) }) + it('should get leaderboard entries if the scope is valid', async () => { + const leaderboard = await new LeaderboardFactory([game]).one() + + apiKey.scopes = [APIKeyScope.READ_LEADERBOARDS] + const players = await new PlayerFactory([game]).many(3) + const entries = await new LeaderboardEntryFactory(leaderboard, players).many(5) + const hiddenEntries = await new LeaderboardEntryFactory(leaderboard, players).state('hidden').many(3) + + await (app.context.em).persistAndFlush([...players, ...entries, ...hiddenEntries]) + token = await createToken(apiKey) + + const res = await request(app.callback()) + .get(`${baseUrl}/${leaderboard.internalName}/entries`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.entries).toHaveLength(entries.length) + + if (leaderboard.sortMode === LeaderboardSortMode.ASC) { + expect(res.body.entries[0].score).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].score) + expect(res.body.entries[0].position).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].position) + } else { + expect(res.body.entries[0].score).toBeGreaterThanOrEqual(res.body.entries[res.body.entries.length - 1].score) + expect(res.body.entries[0].position).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].position) + } + }) + it('should get leaderboard entries for a specific alias', async () => { + const leaderboard = await new LeaderboardFactory([game]).one() + const player = await new PlayerFactory([game]).one() const entries = await new LeaderboardEntryFactory(leaderboard, [player]).with(() => ({ playerAlias: player.aliases[0] })).many(2) @@ -74,9 +104,19 @@ describe('Leaderboards API service - get', () => { .expect(200) expect(res.body.entries).toHaveLength(entries.length) + + if (leaderboard.sortMode === LeaderboardSortMode.ASC) { + expect(res.body.entries[0].score).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].score) + expect(res.body.entries[0].position).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].position) + } else { + expect(res.body.entries[0].score).toBeGreaterThanOrEqual(res.body.entries[res.body.entries.length - 1].score) + expect(res.body.entries[0].position).toBeLessThanOrEqual(res.body.entries[res.body.entries.length - 1].position) + } }) it('should not get leaderboard entries if the scope is not valid', async () => { + const leaderboard = await new LeaderboardFactory([game]).one() + apiKey.scopes = [] await (app.context.em).flush() diff --git a/tests/services/_api/players-api/merge.test.ts b/tests/services/_api/players-api/merge.test.ts index 8a7190be..4916c263 100644 --- a/tests/services/_api/players-api/merge.test.ts +++ b/tests/services/_api/players-api/merge.test.ts @@ -46,7 +46,7 @@ describe('Players API service - merge', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('Missing access key scope(s): read:players, write:players') + expect(res.body).toStrictEqual({ message: 'Missing access key scope(s): read:players, write:players' }) }) @@ -61,7 +61,7 @@ describe('Players API service - merge', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('Missing access key scope(s): write:players') + expect(res.body).toStrictEqual({ message: 'Missing access key scope(s): write:players' }) }) @@ -76,7 +76,7 @@ describe('Players API service - merge', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('Missing access key scope(s): read:players') + expect(res.body).toStrictEqual({ message: 'Missing access key scope(s): read:players' }) }) it('should merge player2 into player1', async () => { diff --git a/tests/services/_public/users-public/use-recovery-code.test.ts b/tests/services/_public/users-public/use-recovery-code.test.ts index 8e03cb86..d3aad645 100644 --- a/tests/services/_public/users-public/use-recovery-code.test.ts +++ b/tests/services/_public/users-public/use-recovery-code.test.ts @@ -78,7 +78,7 @@ describe('Users public service - use recovery code', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body).toStrictEqual({ message: 'Session expired' }) + expect(res.body).toStrictEqual({ message: 'Session expired', sessionExpired: true }) }) it('should not let users login with an invalid recovery code', async () => { diff --git a/tests/services/_public/users-public/verify-2fa.test.ts b/tests/services/_public/users-public/verify-2fa.test.ts index cfc19830..53b93595 100644 --- a/tests/services/_public/users-public/verify-2fa.test.ts +++ b/tests/services/_public/users-public/verify-2fa.test.ts @@ -59,7 +59,7 @@ describe('Users public service - verify 2fa', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body).toStrictEqual({ message: 'Session expired' }) + expect(res.body).toStrictEqual({ message: 'Session expired', sessionExpired: true }) }) it('should not let users verify their 2fa with an invalid code', async () => { diff --git a/tests/services/data-exports/entities.test.ts b/tests/services/data-exports/entities.test.ts index f3046a09..97576c96 100644 --- a/tests/services/data-exports/entities.test.ts +++ b/tests/services/data-exports/entities.test.ts @@ -32,6 +32,6 @@ describe('Data exports service - available entities', () => { .auth(token, { type: 'bearer' }) .expect(200) - expect(res.body.entities).toStrictEqual([ 'events', 'players', 'playerAliases' ]) + expect(res.body.entities).toStrictEqual([ 'events', 'players', 'playerAliases', 'leaderboardEntries' ]) }) }) diff --git a/tests/services/data-exports/index.test.ts b/tests/services/data-exports/index.test.ts index c09a5287..ed3573bb 100644 --- a/tests/services/data-exports/index.test.ts +++ b/tests/services/data-exports/index.test.ts @@ -56,6 +56,6 @@ describe('Data exports service - index', () => { .auth(invalidUserToken, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('You do not have permissions to view data exports') + expect(res.body).toStrictEqual({ message: 'You do not have permissions to view data exports' }) }) }) diff --git a/tests/services/data-exports/post.test.ts b/tests/services/data-exports/post.test.ts index 3b0f0f41..b5fbb525 100644 --- a/tests/services/data-exports/post.test.ts +++ b/tests/services/data-exports/post.test.ts @@ -31,17 +31,6 @@ describe('Data exports service - post', () => { await (app.context.em).getConnection().close() }) - it('should create a data export', async () => { - const res = await request(app.callback()) - .post(`${baseUrl}`) - .send({ gameId: game.id, entities: [DataExportAvailableEntities.PLAYER_ALIASES, DataExportAvailableEntities.PLAYERS, DataExportAvailableEntities.EVENTS] }) - .auth(token, { type: 'bearer' }) - .expect(200) - - expect(res.body.dataExport).toBeTruthy() - expect(res.body.dataExport.entities).toStrictEqual([DataExportAvailableEntities.PLAYER_ALIASES, DataExportAvailableEntities.PLAYERS, DataExportAvailableEntities.EVENTS]) - }) - it('should create a data export for player aliases', async () => { const res = await request(app.callback()) .post(`${baseUrl}`) @@ -75,6 +64,17 @@ describe('Data exports service - post', () => { expect(res.body.dataExport.entities).toStrictEqual([DataExportAvailableEntities.EVENTS]) }) + it('should create a data export for events', async () => { + const res = await request(app.callback()) + .post(`${baseUrl}`) + .send({ gameId: game.id, entities: [DataExportAvailableEntities.LEADERBOARD_ENTRIES] }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.dataExport).toBeTruthy() + expect(res.body.dataExport.entities).toStrictEqual([DataExportAvailableEntities.LEADERBOARD_ENTRIES]) + }) + it('should not create a data export for dev users', async () => { const invalidUser = await new UserFactory().with(() => ({ organisation: game.organisation })).one() await (app.context.em).persistAndFlush(invalidUser) @@ -87,7 +87,7 @@ describe('Data exports service - post', () => { .auth(invalidUserToken, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('You do not have permissions to create data exports') + expect(res.body).toStrictEqual({ message: 'You do not have permissions to create data exports' }) }) it('should not create a data export for users with unconfirmed emails', async () => { @@ -102,7 +102,7 @@ describe('Data exports service - post', () => { .auth(invalidUserToken, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('You need to confirm your email address to create data exports') + expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to create data exports' }) }) it('should not create a data export for empty entities', async () => { diff --git a/tests/services/events/get.test.ts b/tests/services/events/get.test.ts index bb39ea4f..ab620572 100644 --- a/tests/services/events/get.test.ts +++ b/tests/services/events/get.test.ts @@ -83,7 +83,7 @@ describe('Events service - get', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Missing query key: startDate') + expect(res.body).toStrictEqual({ message: 'Missing query key: startDate' }) }) it('should require a valid startDate query key to get events', async () => { @@ -93,7 +93,7 @@ describe('Events service - get', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Invalid start date, please use YYYY-MM-DD or a timestamp') + expect(res.body).toStrictEqual({ message: 'Invalid start date, please use YYYY-MM-DD or a timestamp' }) }) it('should require a startDate that comes before the endDate query key to get events', async () => { @@ -103,7 +103,7 @@ describe('Events service - get', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Invalid start date, it should be before the end date') + expect(res.body).toStrictEqual({ message: 'Invalid start date, it should be before the end date' }) }) it('should require a endDate query key to get events', async () => { @@ -113,7 +113,7 @@ describe('Events service - get', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Missing query key: endDate') + expect(res.body).toStrictEqual({ message: 'Missing query key: endDate' }) }) it('should require a valid endDate query key to get events', async () => { @@ -123,7 +123,7 @@ describe('Events service - get', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Invalid end date, please use YYYY-MM-DD or a timestamp') + expect(res.body).toStrictEqual({ message: 'Invalid end date, please use YYYY-MM-DD or a timestamp' }) }) it('should correctly calculate event changes', async () => { diff --git a/tests/services/leaderboards/post.test.ts b/tests/services/leaderboards/post.test.ts index 369a0df1..3eaf9fdd 100644 --- a/tests/services/leaderboards/post.test.ts +++ b/tests/services/leaderboards/post.test.ts @@ -63,7 +63,7 @@ describe('Leaderboards service - post', () => { .auth(invalidUserToken, { type: 'bearer' }) .expect(403) - expect(res.body.message).toBe('Demo accounts cannot create leaderboards') + expect(res.body).toStrictEqual({ message: 'Demo accounts cannot create leaderboards' }) }) it('should not create a leaderboard for a game the user has no access to', async () => { diff --git a/tests/services/leaderboards/updateEntry.test.ts b/tests/services/leaderboards/updateEntry.test.ts new file mode 100644 index 00000000..6394f2fc --- /dev/null +++ b/tests/services/leaderboards/updateEntry.test.ts @@ -0,0 +1,72 @@ +import { EntityManager } from '@mikro-orm/core' +import Koa from 'koa' +import init from '../../../src/index' +import request from 'supertest' +import User from '../../../src/entities/user' +import { genAccessToken } from '../../../src/lib/auth/buildTokenPair' +import UserFactory from '../../fixtures/UserFactory' +import LeaderboardFactory from '../../fixtures/LeaderboardFactory' +import GameFactory from '../../fixtures/GameFactory' +import Game from '../../../src/entities/game' +import PlayerFactory from '../../fixtures/PlayerFactory' +import Leaderboard from '../../../src/entities/leaderboard' + +const baseUrl = '/leaderboards' + +describe('Leaderboards service - update entry', () => { + let app: Koa + let user: User + let validGame: Game + let token: string + let leaderboard: Leaderboard + + beforeAll(async () => { + app = await init() + + user = await new UserFactory().one() + validGame = await new GameFactory(user.organisation).one() + const players = await new PlayerFactory([validGame]).many(10) + leaderboard = await new LeaderboardFactory([validGame]).one() + + await (app.context.em).persistAndFlush([user, validGame, ...players, leaderboard]) + + token = await genAccessToken(user) + }) + + afterAll(async () => { + await (app.context.em).getConnection().close() + }) + + it('should mark a leaderboard entry as hidden', async () => { + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}/entries/${leaderboard.entries[0].id}`) + .send({ gameId: validGame.id, hidden: true }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.entry.hidden).toBe(true) + }) + + it('should not mark an entry as unhidden if the hidden property isn\'t sent', async () => { + leaderboard.entries[0].hidden = true + await (app.context.em).flush() + + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}/entries/${leaderboard.entries[0].id}`) + .send({ gameId: validGame.id }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.entry.hidden).toBe(true) + }) + + it('should not update a non-existent entry', async () => { + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}/entries/12312321`) + .send({ gameId: validGame.id, hidden: true }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Leaderboard entry not found' }) + }) +}) diff --git a/tests/services/leaderboards/updateLeaderboard.test.ts b/tests/services/leaderboards/updateLeaderboard.test.ts new file mode 100644 index 00000000..60bdec8e --- /dev/null +++ b/tests/services/leaderboards/updateLeaderboard.test.ts @@ -0,0 +1,96 @@ +import { EntityManager } from '@mikro-orm/core' +import Koa from 'koa' +import init from '../../../src/index' +import request from 'supertest' +import User from '../../../src/entities/user' +import { genAccessToken } from '../../../src/lib/auth/buildTokenPair' +import UserFactory from '../../fixtures/UserFactory' +import LeaderboardFactory from '../../fixtures/LeaderboardFactory' +import GameFactory from '../../fixtures/GameFactory' +import Game from '../../../src/entities/game' +import Leaderboard, { LeaderboardSortMode } from '../../../src/entities/leaderboard' + +const baseUrl = '/leaderboards' + +describe('Leaderboards service - update leaderboard', () => { + let app: Koa + let user: User + let validGame: Game + let token: string + let leaderboard: Leaderboard + + beforeAll(async () => { + app = await init() + + user = await new UserFactory().one() + validGame = await new GameFactory(user.organisation).one() + leaderboard = await new LeaderboardFactory([validGame]).one() + + await (app.context.em).persistAndFlush([user, validGame, leaderboard]) + + token = await genAccessToken(user) + }) + + afterAll(async () => { + await (app.context.em).getConnection().close() + }) + + it('should update a leaderboard\'s name', async () => { + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}`) + .send({ gameId: validGame.id, name: 'The new name' }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.leaderboard.name).toBe('The new name') + }) + + it('should update a leaderboard\'s sort mode', async () => { + leaderboard.sortMode = LeaderboardSortMode.DESC + await (app.context.em).flush() + + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}`) + .send({ gameId: validGame.id, sortMode: LeaderboardSortMode.ASC }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.leaderboard.sortMode).toBe('asc') + }) + + it('should not update a leaderboard\'s entry uniqueness mode if the key is not sent', async () => { + leaderboard.unique = true + await (app.context.em).flush() + + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}`) + .send({ gameId: validGame.id }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.leaderboard.unique).toBe(true) + }) + + it('should update a leaderboard\'s entry uniqueness mode', async () => { + leaderboard.unique = true + await (app.context.em).flush() + + const res = await request(app.callback()) + .patch(`${baseUrl}/${leaderboard.internalName}`) + .send({ gameId: validGame.id, unique: false }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.leaderboard.unique).toBe(false) + }) + + it('should not update a non-existent leaderboard', async () => { + const res = await request(app.callback()) + .patch(`${baseUrl}/blah`) + .send({ gameId: validGame.id, name: 'The new name' }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Leaderboard not found' }) + }) +}) diff --git a/tests/services/players/events.test.ts b/tests/services/players/events.test.ts index 45c6ad57..fc870a6f 100644 --- a/tests/services/players/events.test.ts +++ b/tests/services/players/events.test.ts @@ -99,6 +99,6 @@ describe('Players service - get events', () => { .auth(token, { type: 'bearer' }) .expect(404) - expect(res.body.message).toBe('Player not found') + expect(res.body).toStrictEqual({ message: 'Player not found' }) }) }) diff --git a/tests/services/players/patch.test.ts b/tests/services/players/patch.test.ts index dc40b219..adccc53f 100644 --- a/tests/services/players/patch.test.ts +++ b/tests/services/players/patch.test.ts @@ -129,7 +129,7 @@ describe('Players service - patch', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Props must be an array') + expect(res.body).toStrictEqual({ message: 'Props must be an array' }) }) it('should not update a non-existent player\'s properties', async () => { diff --git a/tests/services/players/post.test.ts b/tests/services/players/post.test.ts index 7a8e6ff5..e77e4ca2 100644 --- a/tests/services/players/post.test.ts +++ b/tests/services/players/post.test.ts @@ -74,8 +74,8 @@ describe('Players service - post', () => { .auth(token, { type: 'bearer' }) .expect(200) - expect(res.body.player.props[0].key).toBe('characterName') - expect(res.body.player.props[0].value).toBe('Bob John') + expect(res.body.player.props[0].key).toBe('characterName') + expect(res.body.player.props[0].value).toBe('Bob John') }) it('should not create a player for a non-existent game', async () => { @@ -112,6 +112,6 @@ describe('Players service - post', () => { .auth(token, { type: 'bearer' }) .expect(400) - expect(res.body.message).toBe('Props must be an array') + expect(res.body).toStrictEqual({ message: 'Props must be an array' }) }) })