From c4ad01a605c3f65177f66e17f6cd343ccab443cc Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 11 May 2026 21:16:58 +0100 Subject: [PATCH] add toggle dev build route --- src/entities/game-activity.ts | 3 + src/entities/player.ts | 10 +- src/routes/protected/player/index.ts | 2 + .../protected/player/toggle-dev-build.ts | 52 ++++++ .../protected/player/toggle-dev-build.test.ts | 165 ++++++++++++++++++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/routes/protected/player/toggle-dev-build.ts create mode 100644 tests/routes/protected/player/toggle-dev-build.test.ts diff --git a/src/entities/game-activity.ts b/src/entities/game-activity.ts index ee2610aeb..ea4ec5ebc 100644 --- a/src/entities/game-activity.ts +++ b/src/entities/game-activity.ts @@ -47,6 +47,7 @@ export enum GameActivityType { GAME_FEEDBACK_RESTORED, GAME_FEEDBACK_CATEGORY_RESET, ORGANISATION_MEMBER_REMOVED, + PLAYER_DEV_BUILD_TOGGLED, } @Entity() @@ -168,6 +169,8 @@ export default class GameActivity { return `${this.user.username} reset feedback for the category ${this.extra.feedbackCategoryInternalName}` case GameActivityType.ORGANISATION_MEMBER_REMOVED: return `${this.user.username} removed ${this.extra.removedUsername} from the organisation` + case GameActivityType.PLAYER_DEV_BUILD_TOGGLED: + return `${this.user.username} toggled a player's dev build status` default: return '' } diff --git a/src/entities/player.ts b/src/entities/player.ts index d946c07d1..46cec8b90 100644 --- a/src/entities/player.ts +++ b/src/entities/player.ts @@ -88,6 +88,14 @@ export default class Player { } } + removeProp(key: string) { + const prop = this.props.getItems().find((prop) => prop.key === key) + + if (prop) { + this.props.remove(prop) + } + } + setProps(props: { key: string; value: string }[]) { this.props.set(props.map(({ key, value }) => new PlayerProp(this, key, value))) } @@ -194,7 +202,7 @@ export default class Player { markAsDevBuild() { this.devBuild = true - this.addProp(DEV_BUILD_META_KEY, '1') + this.upsertProp(DEV_BUILD_META_KEY, '1') } async checkGroupMemberships(em: EntityManager) { diff --git a/src/routes/protected/player/index.ts b/src/routes/protected/player/index.ts index 8f7058939..e0bf8fa1e 100644 --- a/src/routes/protected/player/index.ts +++ b/src/routes/protected/player/index.ts @@ -6,6 +6,7 @@ import { getRoute } from './get.js' import { listRoute } from './list.js' import { savesRoute } from './saves.js' import { statsRoute } from './stats.js' +import { toggleDevBuildRoute } from './toggle-dev-build.js' import { updateRoute } from './update.js' export function playerRouter() { @@ -13,6 +14,7 @@ export function playerRouter() { route(listRoute) route(getRoute) route(updateRoute) + route(toggleDevBuildRoute) route(deleteRoute) route(eventsRoute) route(statsRoute) diff --git a/src/routes/protected/player/toggle-dev-build.ts b/src/routes/protected/player/toggle-dev-build.ts new file mode 100644 index 000000000..ee36cecf5 --- /dev/null +++ b/src/routes/protected/player/toggle-dev-build.ts @@ -0,0 +1,52 @@ +import { GameActivityType } from '../../../entities/game-activity.js' +import { DEV_BUILD_META_KEY } from '../../../entities/player.js' +import createGameActivity from '../../../lib/logging/createGameActivity.js' +import { protectedRoute, withMiddleware } from '../../../lib/routing/router.js' +import { loadGame } from '../../../middleware/game-middleware.js' +import { loadPlayer } from './common.js' + +export const toggleDevBuildRoute = protectedRoute({ + method: 'patch', + path: '/:id/toggle-dev-build', + schema: (z) => ({ + body: z.object({ + devBuild: z.boolean(), + }), + }), + middleware: withMiddleware(loadGame, loadPlayer), + handler: async (ctx) => { + const em = ctx.em + const player = ctx.state.player + const { devBuild } = ctx.state.validated.body + + if (devBuild) { + player.markAsDevBuild() + } else { + player.devBuild = false + player.removeProp(DEV_BUILD_META_KEY) + } + + createGameActivity(em, { + user: ctx.state.user, + game: ctx.state.game, + type: GameActivityType.PLAYER_DEV_BUILD_TOGGLED, + extra: { + playerId: player.id, + devBuild, + display: { + Player: player.id, + 'Dev build': devBuild ? 'true' : 'false', + }, + }, + }) + + await em.flush() + + return { + status: 200, + body: { + player, + }, + } + }, +}) diff --git a/tests/routes/protected/player/toggle-dev-build.test.ts b/tests/routes/protected/player/toggle-dev-build.test.ts new file mode 100644 index 000000000..095338318 --- /dev/null +++ b/tests/routes/protected/player/toggle-dev-build.test.ts @@ -0,0 +1,165 @@ +import request from 'supertest' +import GameActivity, { GameActivityType } from '../../../../src/entities/game-activity.js' +import { DEV_BUILD_META_KEY } from '../../../../src/entities/player.js' +import { UserType } from '../../../../src/entities/user.js' +import PlayerFactory from '../../../fixtures/PlayerFactory.js' +import createOrganisationAndGame from '../../../utils/createOrganisationAndGame.js' +import createUserAndToken from '../../../utils/createUserAndToken.js' + +describe('Player - toggle dev build', () => { + it('should not toggle dev build for a player in a game the user has no access to', async () => { + const [, otherGame] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }) + + const player = await new PlayerFactory([otherGame]).one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/games/${otherGame.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: true }) + .auth(token, { type: 'bearer' }) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'Forbidden' }) + }) + + it('should not toggle dev build for a non-existent player', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const res = await request(app) + .patch(`/games/${game.id}/players/non-existent-id/toggle-dev-build`) + .send({ devBuild: true }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Player not found' }) + }) + + it('should set devBuild to true and add the META_DEV_BUILD prop when devBuild is true', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]).one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: true }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.devBuild).toBe(true) + + await em.refresh(player) + expect(player.devBuild).toBe(true) + expect(player.props.getItems().some((p) => p.key === DEV_BUILD_META_KEY)).toBe(true) + }) + + it('should set devBuild to false and remove the META_DEV_BUILD prop when devBuild is false', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]).devBuild().one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: false }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.devBuild).toBe(false) + + await em.refresh(player) + expect(player.devBuild).toBe(false) + expect(player.props.getItems().some((p) => p.key === DEV_BUILD_META_KEY)).toBe(false) + }) + + it('should create a PLAYER_DEV_BUILD_TOGGLED activity when enabling dev build', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]).one() + await em.persist(player).flush() + + await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: true }) + .auth(token, { type: 'bearer' }) + .expect(200) + + const activity = await em.repo(GameActivity).findOne({ + type: GameActivityType.PLAYER_DEV_BUILD_TOGGLED, + game, + extra: { playerId: player.id }, + }) + + expect(activity).not.toBeNull() + expect(activity?.extra.devBuild).toBe(true) + }) + + it('should create a PLAYER_DEV_BUILD_TOGGLED activity when disabling dev build', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]).devBuild().one() + await em.persist(player).flush() + + await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: false }) + .auth(token, { type: 'bearer' }) + .expect(200) + + const activity = await em.repo(GameActivity).findOne({ + type: GameActivityType.PLAYER_DEV_BUILD_TOGGLED, + game, + extra: { playerId: player.id }, + }) + + expect(activity).not.toBeNull() + expect(activity?.extra.devBuild).toBe(false) + }) + + it('should toggle dev build off for a player without the META_DEV_BUILD prop', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]) + .state(() => ({ + devBuild: true, + })) + .one() + await em.persist(player).flush() + + const res = await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: false }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.player.devBuild).toBe(false) + + await em.refresh(player) + expect(player.props.getItems().some((p) => p.key === DEV_BUILD_META_KEY)).toBe(false) + }) + + it('should be idempotent when enabling dev build on a player that is already a dev build', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const player = await new PlayerFactory([game]).devBuild().one() + await em.persist(player).flush() + + await request(app) + .patch(`/games/${game.id}/players/${player.id}/toggle-dev-build`) + .send({ devBuild: true }) + .auth(token, { type: 'bearer' }) + .expect(200) + + await em.refresh(player) + expect(player.devBuild).toBe(true) + expect(player.props.getItems().some((p) => p.key === DEV_BUILD_META_KEY)).toBe(true) + }) +})