diff --git a/package-lock.json b/package-lock.json index 99b01ea2..ddfed8e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.44.0", + "version": "0.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.44.0", + "version": "0.45.0", "license": "MIT", "dependencies": { "@clickhouse/client": "^1.4.1", @@ -641,22 +641,24 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -2098,9 +2100,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "version": "4.17.9", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz", + "integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==", "dev": true, "license": "MIT" }, @@ -3899,16 +3901,17 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -3986,6 +3989,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", diff --git a/package.json b/package.json index fdd1016d..4dd2701a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.44.0", + "version": "0.45.0", "description": "", "main": "src/index.ts", "scripts": { diff --git a/src/entities/index.ts b/src/entities/index.ts index 2e4da0d6..cfdeaf75 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,3 +1,4 @@ +import UserPinnedGroup from './user-pinned-group' import PlayerAuthActivity from './player-auth-activity' import PlayerAuth from './player-auth' import GameFeedback from './game-feedback' @@ -33,6 +34,7 @@ import PlayerGroup from './player-group' import GameSecret from './game-secret' export default [ + UserPinnedGroup, PlayerAuthActivity, PlayerAuth, GameFeedback, diff --git a/src/entities/subscribers/player.subscriber.ts b/src/entities/subscribers/player.subscriber.ts index 1f35590e..2bc9d854 100644 --- a/src/entities/subscribers/player.subscriber.ts +++ b/src/entities/subscribers/player.subscriber.ts @@ -7,7 +7,9 @@ export default class PlayerSubscriber implements EventSubscriber { const em = (args.em as EntityManager).fork() const changeSets = args.uow.getChangeSets() - const cs = changeSets.find((cs) => [ChangeSetType.CREATE, ChangeSetType.UPDATE].includes(cs.type) && cs.entity instanceof Player) + const cs = changeSets.find((cs) => { + return [ChangeSetType.CREATE, ChangeSetType.UPDATE].includes(cs.type) && cs.entity instanceof Player + }) if (cs) { await checkGroupMemberships(em, cs.entity as Player) diff --git a/src/entities/user-pinned-group.ts b/src/entities/user-pinned-group.ts new file mode 100644 index 00000000..1ed327de --- /dev/null +++ b/src/entities/user-pinned-group.ts @@ -0,0 +1,24 @@ +import { Entity, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/mysql' +import User from './user' +import PlayerGroup from './player-group' + +@Entity() +@Unique({ properties: ['user', 'group'] }) +export default class UserPinnedGroup { + @PrimaryKey() + id: number + + @ManyToOne(() => User, { eager: true }) + user: User + + @ManyToOne(() => PlayerGroup, { eager: true }) + group: PlayerGroup + + @Property() + createdAt: Date = new Date() + + constructor(user: User, group: PlayerGroup) { + this.user = user + this.group = group + } +} diff --git a/src/lib/dates/dateValidationSchema.ts b/src/lib/dates/dateValidationSchema.ts index 1140087e..00829a8c 100644 --- a/src/lib/dates/dateValidationSchema.ts +++ b/src/lib/dates/dateValidationSchema.ts @@ -1,4 +1,4 @@ -import { isBefore, isValid } from 'date-fns' +import { isBefore, isSameDay, isValid } from 'date-fns' import { Request, Validatable, ValidationCondition } from 'koa-clay' const schema: Validatable = { @@ -15,7 +15,7 @@ const schema: Validatable = { break: true }, { - check: isValid(endDate) ? isBefore(startDate, endDate) : true, + check: isValid(endDate) ? (isBefore(startDate, endDate) || isSameDay(startDate, endDate)) : true, error: 'Invalid start date, it should be before the end date' } ] diff --git a/src/lib/groups/checkGroupMemberships.ts b/src/lib/groups/checkGroupMemberships.ts index 7f004fc0..058e30b4 100644 --- a/src/lib/groups/checkGroupMemberships.ts +++ b/src/lib/groups/checkGroupMemberships.ts @@ -14,7 +14,7 @@ export default async function checkGroupMemberships(em: EntityManager, player: P if (playerIsEligible && !playerCurrentlyInGroup) { group.members.add(player) - } else if (playerCurrentlyInGroup) { + } else if (!playerIsEligible && playerCurrentlyInGroup) { group.members.remove(group.members.getItems().find((member) => member.id === player.id)) } } diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index b21c58c6..76410afd 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -3122,6 +3122,122 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "int", + "unsigned": true, + "autoincrement": true, + "primary": true, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "user_id": { + "name": "user_id", + "type": "int", + "unsigned": true, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 255, + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "datetime" + } + }, + "name": "user_pinned_group", + "indexes": [ + { + "columnNames": [ + "user_id" + ], + "composite": false, + "keyName": "user_pinned_group_user_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "columnNames": [ + "group_id" + ], + "composite": false, + "keyName": "user_pinned_group_group_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "user_pinned_group_user_id_group_id_unique", + "columnNames": [ + "user_id", + "group_id" + ], + "composite": true, + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "PRIMARY", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "user_pinned_group_user_id_foreign": { + "constraintName": "user_pinned_group_user_id_foreign", + "columnNames": [ + "user_id" + ], + "localTableName": "user_pinned_group", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "user", + "updateRule": "cascade" + }, + "user_pinned_group_group_id_foreign": { + "constraintName": "user_pinned_group_group_id_foreign", + "columnNames": [ + "group_id" + ], + "localTableName": "user_pinned_group", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "player_group", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { diff --git a/src/migrations/20241001194252CreateUserPinnedGroupsTable.ts b/src/migrations/20241001194252CreateUserPinnedGroupsTable.ts new file mode 100644 index 00000000..ec2ebc23 --- /dev/null +++ b/src/migrations/20241001194252CreateUserPinnedGroupsTable.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations' + +export class CreateUserPinnedGroupsTable extends Migration { + + override async up(): Promise { + this.addSql('create table `user_pinned_group` (`id` int unsigned not null auto_increment primary key, `user_id` int unsigned not null, `group_id` varchar(255) not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') + this.addSql('alter table `user_pinned_group` add index `user_pinned_group_user_id_index`(`user_id`);') + this.addSql('alter table `user_pinned_group` add index `user_pinned_group_group_id_index`(`group_id`);') + this.addSql('alter table `user_pinned_group` add unique `user_pinned_group_user_id_group_id_unique`(`user_id`, `group_id`);') + + this.addSql('alter table `user_pinned_group` add constraint `user_pinned_group_user_id_foreign` foreign key (`user_id`) references `user` (`id`) on update cascade;') + this.addSql('alter table `user_pinned_group` add constraint `user_pinned_group_group_id_foreign` foreign key (`group_id`) references `player_group` (`id`) on update cascade;') + } + + override async down(): Promise { + this.addSql('drop table if exists `user_pinned_group`;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 9edf9d9f..b93724b6 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -29,6 +29,7 @@ import { CreatePlayerAuthActivityTable } from './20240725183402CreatePlayerAuthA import { UpdatePlayerAliasServiceColumn } from './20240916213402UpdatePlayerAliasServiceColumn' import { AddPlayerAliasAnonymisedColumn } from './20240920121232AddPlayerAliasAnonymisedColumn' import { AddLeaderboardEntryPropsColumn } from './20240922222426AddLeaderboardEntryPropsColumn' +import { CreateUserPinnedGroupsTable } from './20241001194252CreateUserPinnedGroupsTable' export default [ { @@ -154,5 +155,9 @@ export default [ { name: 'AddLeaderboardEntryPropsColumn', class: AddLeaderboardEntryPropsColumn + }, + { + name: 'CreateUserPinnedGroupsTable', + class: CreateUserPinnedGroupsTable } ] diff --git a/src/policies/player-group.policy.ts b/src/policies/player-group.policy.ts index 64890fda..5163985f 100644 --- a/src/policies/player-group.policy.ts +++ b/src/policies/player-group.policy.ts @@ -35,4 +35,18 @@ export default class PlayerGroupPolicy extends Policy { return await this.canAccessGame(Number(gameId)) } + + async indexPinned(req: Request): Promise { + const { gameId } = req.params + return await this.canAccessGame(Number(gameId)) + } + + async togglePinned(req: Request): Promise { + const { gameId, id } = req.params + + this.ctx.state.group = await this.em.getRepository(PlayerGroup).findOne(id) + if (!this.ctx.state.group) return new PolicyDenial({ message: 'Group not found' }, 404) + + return await this.canAccessGame(Number(gameId)) + } } diff --git a/src/services/player-group.service.ts b/src/services/player-group.service.ts index a7ca891e..4df9d37f 100644 --- a/src/services/player-group.service.ts +++ b/src/services/player-group.service.ts @@ -6,6 +6,8 @@ import PlayerGroupRule, { PlayerGroupRuleCastType, PlayerGroupRuleName } from '. import { ruleModeValidation, rulesValidation } from '../lib/groups/rulesValidation' import createGameActivity from '../lib/logging/createGameActivity' import PlayerGroupPolicy from '../policies/player-group.policy' +import getUserFromToken from '../lib/auth/getUserFromToken' +import UserPinnedGroup from '../entities/user-pinned-group' type PlayerGroupWithCount = Pick & { count: number } @@ -31,6 +33,16 @@ type PlayerGroupWithCount = Pick { + const em: EntityManager = req.ctx.em + const user = await getUserFromToken(req.ctx) + + const pinnedGroups = await em.getRepository(UserPinnedGroup).find({ + user, + group: { + game: req.ctx.state.game + } + }, { + orderBy: { createdAt: 'desc' } + }) + + const groups = await Promise.all(pinnedGroups.map(({ group }) => this.groupWithCount(group))) + + return { + status: 200, + body: { + groups + } + } + } + + @HasPermission(PlayerGroupPolicy, 'togglePinned') + async togglePinned(req: Request): Promise { + const { pinned } = req.body + const em: EntityManager = req.ctx.em + + const group: PlayerGroup = req.ctx.state.group + const user = await getUserFromToken(req.ctx) + + const pinnedGroup = await em.getRepository(UserPinnedGroup).findOne({ user, group }) + if (pinned && !pinnedGroup) { + em.persist(new UserPinnedGroup(user, group)) + } else if (!pinned && pinnedGroup) { + em.remove(pinnedGroup) + } + + await em.flush() + + return { + status: 204 + } + } } diff --git a/src/services/player.service.ts b/src/services/player.service.ts index 6c61b6f7..c14e9783 100644 --- a/src/services/player.service.ts +++ b/src/services/player.service.ts @@ -247,6 +247,9 @@ export default class PlayerService extends Service { await em.flush() + // refresh groups + await em.refresh(player) + return { status: 200, body: { diff --git a/src/services/public/user-public.service.ts b/src/services/public/user-public.service.ts index bed01c45..d277444a 100644 --- a/src/services/public/user-public.service.ts +++ b/src/services/public/user-public.service.ts @@ -121,7 +121,7 @@ export default class UserPublicService extends Service { createGameActivity(em, { user, type: GameActivityType.INVITE_ACCEPTED }) - await em.remove(invite) + em.remove(invite) } else { const organisation = new Organisation() organisation.email = email diff --git a/src/services/user.service.ts b/src/services/user.service.ts index a543c62a..1cb57291 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -110,7 +110,7 @@ export default class UserService extends Service { user.password = await bcrypt.hash(newPassword, 10) const userSessionRepo = em.getRepository(UserSession) const sessions = await userSessionRepo.find({ user }) - await em.remove(sessions) + em.remove(sessions) const accessToken = await buildTokenPair(req.ctx, user) diff --git a/tests/fixtures/UserPinnedGroupFactory.ts b/tests/fixtures/UserPinnedGroupFactory.ts new file mode 100644 index 00000000..38795ae1 --- /dev/null +++ b/tests/fixtures/UserPinnedGroupFactory.ts @@ -0,0 +1,24 @@ +import { Factory } from 'hefty' +import UserPinnedGroup from '../../src/entities/user-pinned-group' +import UserFactory from './UserFactory' +import PlayerGroupFactory from './PlayerGroupFactory' +import GameFactory from './GameFactory' +import OrganisationFactory from './OrganisationFactory' + +export default class UserPinnedGroupFactory extends Factory { + constructor() { + super(UserPinnedGroup) + } + + protected definition(): void { + this.state(async () => { + const organisation = await new OrganisationFactory().one() + const game = await new GameFactory(organisation).one() + + return { + user: await new UserFactory().state(() => ({ organisation })).one(), + group: await new PlayerGroupFactory().state(() => ({ game })).one() + } + }) + } +} diff --git a/tests/services/_api/player-api/patch.test.ts b/tests/services/_api/player-api/patch.test.ts index 761aa84a..d6269bd4 100644 --- a/tests/services/_api/player-api/patch.test.ts +++ b/tests/services/_api/player-api/patch.test.ts @@ -151,6 +151,42 @@ describe('Player API service - patch', () => { ]) }) + it('should remove players from a group when they are no longer eligible', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_PLAYERS]) + + const rule = new PlayerGroupRule(PlayerGroupRuleName.LTE, 'props.currentLevel') + rule.castType = PlayerGroupRuleCastType.DOUBLE + rule.operands = ['59'] + + const group = await new PlayerGroupFactory().construct(apiKey.game).state(() => ({ rules: [rule] })).one() + + const player = await new PlayerFactory([apiKey.game]).state((player) => ({ + props: new Collection(player, [ + new PlayerProp(player, 'collectibles', '0'), + new PlayerProp(player, 'currentLevel', '59') + ]) + })).one() + await (global.em).persistAndFlush([group, player]) + + await (global.em).refresh(group, { populate: ['members'] }) + expect(group.members).toHaveLength(1) + + const res = await request(global.app) + .patch(`/v1/players/${player.id}`) + .send({ + props: [ + { + key: 'currentLevel', + value: '60' + } + ] + }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.groups).toHaveLength(0) + }) + it('should filter keys starting with META_', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_PLAYERS]) diff --git a/tests/services/player-group/indexPinned.test.ts b/tests/services/player-group/indexPinned.test.ts new file mode 100644 index 00000000..087297eb --- /dev/null +++ b/tests/services/player-group/indexPinned.test.ts @@ -0,0 +1,48 @@ +import { EntityManager } from '@mikro-orm/mysql' +import request from 'supertest' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import createUserAndToken from '../../utils/createUserAndToken' +import UserPinnedGroupFactory from '../../fixtures/UserPinnedGroupFactory' +import PlayerGroupFactory from '../../fixtures/PlayerGroupFactory' + +describe('Player group service - index pinned', () => { + it('should return a list of groups', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({}, organisation) + + const pinned = await new UserPinnedGroupFactory() + .state(() => ({ user })) + .state(async () => ({ group: await new PlayerGroupFactory().construct(game).one() })) + .many(3) + + await (global.em).persistAndFlush(pinned) + + const res = await request(global.app) + .get(`/games/${game.id}/player-groups/pinned`) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.groups).toHaveLength(pinned.length) + }) + + it('should not return groups for a non-existent game', async () => { + const [token] = await createUserAndToken() + + const res = await request(global.app) + .get('/games/99999/player-groups/pinned') + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Game not found' }) + }) + + it('should not return groups for a game the user has no access to', async () => { + const [, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken() + + await request(global.app) + .get(`/games/${game.id}/player-groups/pinned`) + .auth(token, { type: 'bearer' }) + .expect(403) + }) +}) diff --git a/tests/services/player-group/togglePinned.test.ts b/tests/services/player-group/togglePinned.test.ts new file mode 100644 index 00000000..83f3e7d3 --- /dev/null +++ b/tests/services/player-group/togglePinned.test.ts @@ -0,0 +1,104 @@ +import { EntityManager } from '@mikro-orm/mysql' +import request from 'supertest' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import createUserAndToken from '../../utils/createUserAndToken' +import UserPinnedGroupFactory from '../../fixtures/UserPinnedGroupFactory' +import PlayerGroupFactory from '../../fixtures/PlayerGroupFactory' +import UserPinnedGroup from '../../../src/entities/user-pinned-group' + +describe('Player group service - toggle pinned', () => { + it('should pin a group', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const group = await new PlayerGroupFactory().construct(game).one() + await (global.em).persistAndFlush(group) + + await request(global.app) + .put(`/games/${game.id}/player-groups/${group.id}/toggle-pinned`) + .send({ pinned: true }) + .auth(token, { type: 'bearer' }) + .expect(204) + + expect(await (global.em).find(UserPinnedGroup, { group })).toHaveLength(1) + }) + + it('should unpin a group', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({}, organisation) + + const group = await new PlayerGroupFactory().construct(game).one() + const pinnedGroup = await new UserPinnedGroupFactory().state(() => ({ user, group })).one() + await (global.em).persistAndFlush([group, pinnedGroup]) + + await request(global.app) + .put(`/games/${game.id}/player-groups/${group.id}/toggle-pinned`) + .send({ pinned: false }) + .auth(token, { type: 'bearer' }) + .expect(204) + + expect(await (global.em).find(UserPinnedGroup, { group })).toHaveLength(0) + }) + + it('should not re-pin a group', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({}, organisation) + + const group = await new PlayerGroupFactory().construct(game).one() + const pinnedGroup = await new UserPinnedGroupFactory().state(() => ({ user, group })).one() + await (global.em).persistAndFlush([group, pinnedGroup]) + + await request(global.app) + .put(`/games/${game.id}/player-groups/${group.id}/toggle-pinned`) + .send({ pinned: true }) + .auth(token, { type: 'bearer' }) + .expect(204) + + expect(await (global.em).find(UserPinnedGroup, { group })).toHaveLength(1) + }) + + it('should handle unpinning a group that isn\'t pinned', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const group = await new PlayerGroupFactory().construct(game).one() + await (global.em).persistAndFlush(group) + + await request(global.app) + .put(`/games/${game.id}/player-groups/${group.id}/toggle-pinned`) + .send({ pinned: false }) + .auth(token, { type: 'bearer' }) + .expect(204) + + expect(await (global.em).find(UserPinnedGroup, { group })).toHaveLength(0) + }) + + it('should not update a group for a game the user has no access to', async () => { + const [, otherGame] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}) + + const group = await new PlayerGroupFactory().construct(otherGame).one() + await (global.em).persistAndFlush(group) + + const res = await request(global.app) + .put(`/games/${otherGame.id}/player-groups/${group.id}/toggle-pinned`) + .send({ pinned: true }) + .auth(token, { type: 'bearer' }) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'Forbidden' }) + }) + + it('should not update a non-existent group', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const res = await request(global.app) + .put(`/games/${game.id}/player-groups/4324234/toggle-pinned`) + .send({ pinned: true }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Group not found' }) + }) +})