Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/entities/game-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
GAME_FEEDBACK_RESTORED,
GAME_FEEDBACK_CATEGORY_RESET,
ORGANISATION_MEMBER_REMOVED,
PLAYER_DEV_BUILD_TOGGLED,
}

@Entity()
Expand Down Expand Up @@ -85,27 +86,27 @@
case GameActivityType.PLAYER_PROPS_UPDATED:
return `${this.user.username} updated a player's props`
case GameActivityType.LEADERBOARD_CREATED:
return `${this.user.username} created the leaderboard ${this.extra.leaderboardInternalName}`

Check warning on line 89 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.LEADERBOARD_UPDATED:
return `${this.user.username} updated the leaderboard ${this.extra.leaderboardInternalName}`

Check warning on line 91 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.LEADERBOARD_DELETED:
return `${this.user.username} deleted the leaderboard ${this.extra.leaderboardInternalName}`

Check warning on line 93 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.LEADERBOARD_ENTRY_HIDDEN:
return `${this.user.username} hid a leaderboard entry in ${this.extra.leaderboardInternalName}`

Check warning on line 95 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.LEADERBOARD_ENTRY_RESTORED:
return `${this.user.username} restored a leaderboard entry in ${this.extra.leaderboardInternalName}`

Check warning on line 97 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.API_KEY_CREATED:
return `${this.user.username} created an access key`
case GameActivityType.API_KEY_REVOKED:
return `${this.user.username} revoked an access key`
case GameActivityType.GAME_STAT_CREATED:
return `${this.user.username} created the stat ${this.extra.statInternalName}`

Check warning on line 103 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.GAME_STAT_UPDATED:
return `${this.user.username} updated the stat ${this.extra.statInternalName}`

Check warning on line 105 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.GAME_STAT_DELETED:
return `${this.user.username} deleted the stat ${this.extra.statInternalName}`

Check warning on line 107 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.INVITE_CREATED:
return `${this.user.username} created an invite for ${this.extra.inviteEmail}`

Check warning on line 109 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.INVITE_ACCEPTED:
return `${this.user.username} joined the organisation`
case GameActivityType.DATA_EXPORT_REQUESTED:
Expand All @@ -121,7 +122,7 @@
case GameActivityType.GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED:
return `${this.user.username} initiated a manual sync for Steamworks stats`
case GameActivityType.PLAYER_GROUP_CREATED:
return `${this.user.username} created the group ${this.extra.groupName}`

Check warning on line 125 in src/entities/game-activity.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(restrict-template-expressions)

Invalid type used in template literal expression.
case GameActivityType.PLAYER_GROUP_UPDATED:
return `${this.user.username} updated the group ${this.extra.groupName}`
case GameActivityType.PLAYER_GROUP_DELETED:
Expand Down Expand Up @@ -168,6 +169,8 @@
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 ''
}
Expand Down
10 changes: 9 additions & 1 deletion src/entities/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/routes/protected/player/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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() {
return protectedRouter('/games/:gameId/players', ({ route }) => {
route(listRoute)
route(getRoute)
route(updateRoute)
route(toggleDevBuildRoute)
route(deleteRoute)
route(eventsRoute)
route(statsRoute)
Expand Down
52 changes: 52 additions & 0 deletions src/routes/protected/player/toggle-dev-build.ts
Original file line number Diff line number Diff line change
@@ -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,
},
}
},
})
165 changes: 165 additions & 0 deletions tests/routes/protected/player/toggle-dev-build.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})