From a3f083ad731a4d6d2924fc579854afc3c099906c Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:41:28 +0100 Subject: [PATCH] add channel storage prop array support --- src/entities/game-channel-storage-prop.ts | 35 +- src/lib/props/sanitiseProps.ts | 2 +- src/routes/api/game-channel/get-storage.ts | 22 +- src/routes/api/game-channel/list-storage.ts | 52 +-- src/routes/api/game-channel/put-storage.ts | 292 +++++++++++------ .../api/game-channel/get-storage.test.ts | 129 +++++++- .../api/game-channel/list-storage.test.ts | 108 ++++++- .../api/game-channel/put-storage.test.ts | 305 ++++++++++++++++++ 8 files changed, 811 insertions(+), 134 deletions(-) diff --git a/src/entities/game-channel-storage-prop.ts b/src/entities/game-channel-storage-prop.ts index eb2ccfa1..6fdc9c09 100644 --- a/src/entities/game-channel-storage-prop.ts +++ b/src/entities/game-channel-storage-prop.ts @@ -1,5 +1,6 @@ import { Entity, Index, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' import Redis from 'ioredis' +import { isArrayKey } from '../lib/props/sanitiseProps' import GameChannel from './game-channel' import PlayerAlias from './player-alias' import { MAX_KEY_LENGTH, MAX_VALUE_LENGTH } from './prop' @@ -43,10 +44,38 @@ export default class GameChannelStorageProp { this.value = value } - persistToRedis(redis: Redis) { - const redisKey = GameChannelStorageProp.getRedisKey(this.gameChannel.id, this.key) + static flatten( + props: GameChannelStorageProp[], + ): ReturnType | null { + if (props.length === 0) { + return null + } + + if (props.length === 1 && !isArrayKey(props[0].key)) { + return props[0].toJSON() + } + + const sorted = [...props].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + const representative = sorted[0].toJSON() + representative.value = JSON.stringify(sorted.map((p) => p.value)) + return representative + } + + static persistToRedis({ + redis, + channelId, + key, + props, + }: { + redis: Redis + channelId: number + key: string + props: GameChannelStorageProp[] + }) { + const flattened = GameChannelStorageProp.flatten(props) + const redisKey = GameChannelStorageProp.getRedisKey(channelId, key) const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds - return redis.set(redisKey, JSON.stringify(this), 'EX', expirationSeconds) + return redis.set(redisKey, JSON.stringify(flattened), 'EX', expirationSeconds) } toJSON() { diff --git a/src/lib/props/sanitiseProps.ts b/src/lib/props/sanitiseProps.ts index aef24bb5..6f0f2d73 100644 --- a/src/lib/props/sanitiseProps.ts +++ b/src/lib/props/sanitiseProps.ts @@ -6,7 +6,7 @@ export const MAX_ARRAY_LENGTH = 1000 type UnsanitisedProp = { key: string; value: string | null } -function isArrayKey(key: string): boolean { +export function isArrayKey(key: string): boolean { return key.endsWith('[]') } diff --git a/src/routes/api/game-channel/get-storage.ts b/src/routes/api/game-channel/get-storage.ts index 3dfae68e..ab24c910 100644 --- a/src/routes/api/game-channel/get-storage.ts +++ b/src/routes/api/game-channel/get-storage.ts @@ -39,33 +39,35 @@ export const getStorageRoute = apiRoute({ return ctx.throw(403, 'This player is not a member of the channel') } - let result: GameChannelStorageProp | null = null - const redis: Redis = ctx.redis - const cachedProp = await redis.get(GameChannelStorageProp.getRedisKey(channel.id, propKey)) + const cached = await redis.get(GameChannelStorageProp.getRedisKey(channel.id, propKey)) - if (cachedProp) { + if (cached) { return { status: 200, body: { - prop: JSON.parse(cachedProp), + prop: JSON.parse(cached), }, } } - result = await em.repo(GameChannelStorageProp).findOne({ + const results = await em.repo(GameChannelStorageProp).find({ gameChannel: channel, key: propKey, }) - if (result) { - await result.persistToRedis(redis) - } + const prop = GameChannelStorageProp.flatten(results) + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: propKey, + props: results, + }) return { status: 200, body: { - prop: result, + prop, }, } }, diff --git a/src/routes/api/game-channel/list-storage.ts b/src/routes/api/game-channel/list-storage.ts index 02fde801..eba8496f 100644 --- a/src/routes/api/game-channel/list-storage.ts +++ b/src/routes/api/game-channel/list-storage.ts @@ -9,6 +9,8 @@ import { requireScopes } from '../../../middleware/policy-middleware' import { loadChannel } from './common' import { listStorageDocs } from './docs' +type FlattenedProp = ReturnType + export const listStorageRoute = apiRoute({ method: 'get', path: '/:id/storage/list', @@ -50,19 +52,18 @@ export const listStorageRoute = apiRoute({ return ctx.throw(403, 'This player is not a member of the channel') } - const keys = propKeys - const redisKeys = keys.map((key) => GameChannelStorageProp.getRedisKey(channel.id, key)) - const cachedProps = await redis.mget(...redisKeys) + const redisKeys = propKeys.map((key) => GameChannelStorageProp.getRedisKey(channel.id, key)) + const cachedValues = await redis.mget(...redisKeys) - const resultMap = new Map() + const resultMap = new Map() const missingKeys: string[] = [] - cachedProps.forEach((cachedProp, index) => { - const originalKey = keys[index] - if (cachedProp) { - resultMap.set(originalKey, JSON.parse(cachedProp)) + cachedValues.forEach((cached, index) => { + const key = propKeys[index] + if (cached) { + resultMap.set(key, JSON.parse(cached)) } else { - missingKeys.push(originalKey) + missingKeys.push(key) } }) @@ -72,24 +73,31 @@ export const listStorageRoute = apiRoute({ key: { $in: missingKeys }, }) - // cache the results using a single operation - if (propsFromDB.length > 0) { - const pipeline = redis.pipeline() + const propsByKey = new Map() + for (const prop of propsFromDB) { + const group = propsByKey.get(prop.key) ?? [] + group.push(prop) + propsByKey.set(prop.key, group) + } - for (const prop of propsFromDB) { - resultMap.set(prop.key, prop) - const redisKey = GameChannelStorageProp.getRedisKey(channel.id, prop.key) - const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds - pipeline.set(redisKey, JSON.stringify(prop), 'EX', expirationSeconds) - } + const pipeline = redis.pipeline() - await pipeline.exec() + for (const key of missingKeys) { + const rows = propsByKey.get(key) ?? [] + const flattened = GameChannelStorageProp.flatten(rows) + resultMap.set(key, flattened) + const redisKey = GameChannelStorageProp.getRedisKey(channel.id, key) + const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds + pipeline.set(redisKey, JSON.stringify(flattened), 'EX', expirationSeconds) } + + await pipeline.exec() } - const props = keys - .map((key) => resultMap.get(key)) - .filter((prop): prop is GameChannelStorageProp => prop !== undefined) + const props = propKeys.flatMap((key) => { + const prop = resultMap.get(key) + return prop ? [prop] : [] + }) return { status: 200, diff --git a/src/routes/api/game-channel/put-storage.ts b/src/routes/api/game-channel/put-storage.ts index b706bc8f..5ab1fead 100644 --- a/src/routes/api/game-channel/put-storage.ts +++ b/src/routes/api/game-channel/put-storage.ts @@ -1,9 +1,16 @@ -import { LockMode } from '@mikro-orm/mysql' +import { EntityManager, LockMode } from '@mikro-orm/mysql' import Redis from 'ioredis' import { APIKeyScope } from '../../../entities/api-key' +import GameChannel from '../../../entities/game-channel' import GameChannelStorageProp from '../../../entities/game-channel-storage-prop' +import PlayerAlias from '../../../entities/player-alias' import { PropSizeError } from '../../../lib/errors/propSizeError' -import { sanitiseProps, testPropSize } from '../../../lib/props/sanitiseProps' +import { + isArrayKey, + MAX_ARRAY_LENGTH, + sanitiseProps, + testPropSize, +} from '../../../lib/props/sanitiseProps' import { apiRoute, withMiddleware } from '../../../lib/routing/router' import { numericStringSchema } from '../../../lib/validation/numericStringSchema' import { playerAliasHeaderSchema } from '../../../lib/validation/playerAliasHeaderSchema' @@ -12,10 +19,142 @@ import { requireScopes } from '../../../middleware/policy-middleware' import { loadChannel } from './common' import { putStorageDocs } from './docs' -type GameChannelStorageTransaction = { +type FailedProp = { key: string; error: string } + +type TransactionResult = { upsertedProps: GameChannelStorageProp[] deletedProps: GameChannelStorageProp[] - failedProps: { key: string; error: string }[] + failedProps: FailedProp[] +} + +function tryTestPropSize(key: string, value: string | null): string | null { + try { + testPropSize({ key, value }) + return null + } catch (error) { + if (error instanceof PropSizeError) { + return error.message + } + /* v8 ignore next 2 -- @preserve */ + throw error + } +} + +function processScalars( + trx: EntityManager, + alias: PlayerAlias, + channel: GameChannel, + newScalarMap: Map, + existingProps: GameChannelStorageProp[], +): Pick { + const upsertedProps: GameChannelStorageProp[] = [] + const deletedProps: GameChannelStorageProp[] = [] + const failedProps: FailedProp[] = [] + + const existingScalarMap = new Map(existingProps.map((p) => [p.key, p])) + + for (const [key, value] of newScalarMap.entries()) { + const existingProp = existingScalarMap.get(key) + + if (!value) { + if (existingProp) { + trx.remove(existingProp) + existingProp.lastUpdatedBy = alias + existingProp.updatedAt = new Date() + deletedProps.push(existingProp) + } + continue + } + + const sizeError = tryTestPropSize(key, value) + if (sizeError) { + failedProps.push({ key, error: sizeError }) + continue + } + + if (existingProp) { + existingProp.value = value + existingProp.lastUpdatedBy = alias + upsertedProps.push(existingProp) + } else { + const newProp = new GameChannelStorageProp(channel, key, value) + newProp.createdBy = alias + newProp.lastUpdatedBy = alias + trx.persist(newProp) + upsertedProps.push(newProp) + } + } + + return { upsertedProps, deletedProps, failedProps } +} + +function processArrays( + trx: EntityManager, + alias: PlayerAlias, + channel: GameChannel, + newArrayMap: Map, + existingProps: GameChannelStorageProp[], +): Pick { + const upsertedProps: GameChannelStorageProp[] = [] + const deletedProps: GameChannelStorageProp[] = [] + const failedProps: FailedProp[] = [] + + for (const [key, values] of newArrayMap.entries()) { + const nonNullValues = [...new Set(values.filter((v): v is string => v !== null))] + + const keySizeError = tryTestPropSize(key, null) + if (keySizeError) { + failedProps.push({ key, error: keySizeError }) + continue + } + + const valueSizeError = nonNullValues.map((v) => tryTestPropSize(key, v)).find(Boolean) + if (valueSizeError) { + failedProps.push({ key, error: valueSizeError }) + continue + } + + if (nonNullValues.length > MAX_ARRAY_LENGTH) { + failedProps.push({ + key, + error: `Prop array length (${nonNullValues.length}) for key '${key}' exceeds ${MAX_ARRAY_LENGTH} items`, + }) + continue + } + + const existingForKey = existingProps.filter((p) => p.key === key) + const existingByValue = new Map(existingForKey.map((p) => [p.value, p])) + const createdBy = + existingForKey.length > 0 + ? [...existingForKey].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0] + .createdBy + : alias + + for (const existingProp of existingForKey) { + if (!nonNullValues.includes(existingProp.value)) { + trx.remove(existingProp) + existingProp.lastUpdatedBy = alias + existingProp.updatedAt = new Date() + deletedProps.push(existingProp) + } + } + + for (const value of nonNullValues) { + const existingProp = existingByValue.get(value) + if (existingProp) { + existingProp.lastUpdatedBy = alias + upsertedProps.push(existingProp) + } else { + const newProp = new GameChannelStorageProp(channel, key, value) + newProp.createdBy = createdBy + newProp.lastUpdatedBy = alias + trx.persist(newProp) + upsertedProps.push(newProp) + } + } + } + + return { upsertedProps, deletedProps, failedProps } } export const putStorageRoute = apiRoute({ @@ -51,110 +190,75 @@ export const putStorageRoute = apiRoute({ ), handler: async (ctx) => { const { props } = ctx.state.validated.body - const em = ctx.em - - const channel = ctx.state.channel + const { + em, + redis, + state: { alias, channel }, + } = ctx - if (!channel.hasMember(ctx.state.alias.id)) { + if (!channel.hasMember(alias.id)) { return ctx.throw(403, 'This player is not a member of the channel') } const { upsertedProps, deletedProps, failedProps } = await em.transactional( - async (trx): Promise => { - const newPropsMap = new Map(sanitiseProps({ props }).map(({ key, value }) => [key, value])) - - const upsertedProps: GameChannelStorageTransaction['upsertedProps'] = [] - const deletedProps: GameChannelStorageTransaction['deletedProps'] = [] - const failedProps: GameChannelStorageTransaction['failedProps'] = [] - - if (newPropsMap.size === 0) { - return { - upsertedProps, - deletedProps, - failedProps, - } - } + async (trx): Promise => { + const sanitised = sanitiseProps({ props }) - const existingStorageProps = await trx.repo(GameChannelStorageProp).find( - { - gameChannel: channel, - key: { - $in: Array.from(newPropsMap.keys()), - }, - }, - { lockMode: LockMode.PESSIMISTIC_WRITE }, - ) - - for (const existingProp of existingStorageProps) { - const newPropValue = newPropsMap.get(existingProp.key) - newPropsMap.delete(existingProp.key) - - if (!newPropValue) { - // delete the existing prop and track who deleted it - trx.remove(existingProp) - existingProp.lastUpdatedBy = ctx.state.alias - existingProp.updatedAt = new Date() - deletedProps.push(existingProp) - continue + const newScalarMap = new Map() + const newArrayMap = new Map() + for (const { key, value } of sanitised) { + if (isArrayKey(key)) { + const existing = newArrayMap.get(key) ?? [] + existing.push(value) + newArrayMap.set(key, existing) } else { - try { - testPropSize({ key: existingProp.key, value: newPropValue }) - } catch (error) { - if (error instanceof PropSizeError) { - failedProps.push({ key: existingProp.key, error: error.message }) - continue - /* v8 ignore next 3 -- @preserve */ - } else { - throw error - } - } - - // update the existing prop - existingProp.value = String(newPropValue) - existingProp.lastUpdatedBy = ctx.state.alias - newPropsMap.delete(existingProp.key) - upsertedProps.push(existingProp) + newScalarMap.set(key, value) } } - for (const [key, value] of newPropsMap.entries()) { - if (value) { - try { - testPropSize({ key, value }) - } catch (error) { - if (error instanceof PropSizeError) { - failedProps.push({ key, error: error.message }) - continue - /* v8 ignore next 3 -- @preserve */ - } else { - throw error - } - } - - // create a new prop - const newProp = new GameChannelStorageProp(channel, key, String(value)) - newProp.createdBy = ctx.state.alias - newProp.lastUpdatedBy = ctx.state.alias - trx.persist(newProp) - upsertedProps.push(newProp) - } + if (newScalarMap.size === 0 && newArrayMap.size === 0) { + return { upsertedProps: [], deletedProps: [], failedProps: [] } } - const redis: Redis = ctx.redis - for (const prop of upsertedProps) { - await prop.persistToRedis(redis) - } - for (const prop of deletedProps) { - const redisKey = GameChannelStorageProp.getRedisKey(channel.id, prop.key) - const expirationSeconds = GameChannelStorageProp.redisExpirationSeconds - await redis.set(redisKey, 'null', 'EX', expirationSeconds) - } + const allIncomingKeys = [...newScalarMap.keys(), ...newArrayMap.keys()] + const existingStorageProps = await trx + .repo(GameChannelStorageProp) + .find( + { gameChannel: channel, key: { $in: allIncomingKeys } }, + { lockMode: LockMode.PESSIMISTIC_WRITE }, + ) - return { - upsertedProps, - deletedProps, - failedProps, + const scalarResult = processScalars( + trx, + alias, + channel, + newScalarMap, + existingStorageProps.filter((p) => !isArrayKey(p.key)), + ) + const arrayResult = processArrays( + trx, + alias, + channel, + newArrayMap, + existingStorageProps.filter((p) => isArrayKey(p.key)), + ) + + const upsertedProps = [...scalarResult.upsertedProps, ...arrayResult.upsertedProps] + const deletedProps = [...scalarResult.deletedProps, ...arrayResult.deletedProps] + const failedProps = [...scalarResult.failedProps, ...arrayResult.failedProps] + + const touchedKeys = new Set([...upsertedProps, ...deletedProps].map((p) => p.key)) + for (const key of touchedKeys) { + const remaining = upsertedProps.filter((p) => p.key === key) + await GameChannelStorageProp.persistToRedis({ + redis: redis as Redis, + channelId: channel.id, + key, + props: remaining, + }) } + + return { upsertedProps, deletedProps, failedProps } }, ) diff --git a/tests/routes/api/game-channel/get-storage.test.ts b/tests/routes/api/game-channel/get-storage.test.ts index 1fb43ff6..ce5b2bb2 100644 --- a/tests/routes/api/game-channel/get-storage.test.ts +++ b/tests/routes/api/game-channel/get-storage.test.ts @@ -1,5 +1,7 @@ +import assert from 'node:assert' import request from 'supertest' import { APIKeyScope } from '../../../../src/entities/api-key' +import GameChannelStorageProp from '../../../../src/entities/game-channel-storage-prop' import GameChannelFactory from '../../../fixtures/GameChannelFactory' import GameChannelStoragePropFactory from '../../../fixtures/GameChannelStoragePropFactory' import PlayerFactory from '../../../fixtures/PlayerFactory' @@ -17,7 +19,12 @@ describe('Game channel API - get storage', () => { const prop = await new GameChannelStoragePropFactory(channel).one() await em.persist(prop).flush() - await prop.persistToRedis(redis) + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: prop.key, + props: [prop], + }) const res = await request(app) .get(`/v1/game-channels/${channel.id}/storage`) @@ -74,6 +81,126 @@ describe('Game channel API - get storage', () => { expect(res.body.prop).toBeNull() }) + it('should return all rows for a prop array key', 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.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + + const prop1 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const prop2 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'shield', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + await em.persist([channel, player, prop1, prop2]).flush() + + const res = await request(app) + .get(`/v1/game-channels/${channel.id}/storage`) + .query({ propKey: 'items[]' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.prop.key).toBe('items[]') + expect(JSON.parse(res.body.prop.value).sort()).toStrictEqual(['shield', 'sword']) + }) + + it('should return a single-element array prop as a JSON array', 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.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + + const prop = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + await em.persist([channel, player, prop]).flush() + + const res = await request(app) + .get(`/v1/game-channels/${channel.id}/storage`) + .query({ propKey: 'items[]' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.prop.key).toBe('items[]') + expect(JSON.parse(res.body.prop.value)).toStrictEqual(['sword']) + }) + + it('should return prop arrays from redis if available', 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.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + + const prop1 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const prop2 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'shield', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + await em.persist([channel, player, prop1, prop2]).flush() + + // first request populates the cache + await request(app) + .get(`/v1/game-channels/${channel.id}/storage`) + .query({ propKey: 'items[]' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + // verify the flattened prop is cached + const cachedValue = await redis.get(GameChannelStorageProp.getRedisKey(channel.id, 'items[]')) + assert(cachedValue) + + const parsed = JSON.parse(cachedValue) + expect(parsed.key).toBe('items[]') + expect(JSON.parse(parsed.value).sort()).toStrictEqual(['shield', 'sword']) + + // second request should come from cache + const res = await request(app) + .get(`/v1/game-channels/${channel.id}/storage`) + .query({ propKey: 'items[]' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.prop.key).toBe('items[]') + expect(JSON.parse(res.body.prop.value).sort()).toStrictEqual(['shield', 'sword']) + }) + it('should not return a storage prop if the scope is not valid', async () => { const [apiKey, token] = await createAPIKeyAndToken([]) diff --git a/tests/routes/api/game-channel/list-storage.test.ts b/tests/routes/api/game-channel/list-storage.test.ts index 69d78091..7af51f32 100644 --- a/tests/routes/api/game-channel/list-storage.test.ts +++ b/tests/routes/api/game-channel/list-storage.test.ts @@ -1,5 +1,6 @@ import request from 'supertest' import { APIKeyScope } from '../../../../src/entities/api-key' +import GameChannelStorageProp from '../../../../src/entities/game-channel-storage-prop' import GameChannelFactory from '../../../fixtures/GameChannelFactory' import GameChannelStoragePropFactory from '../../../fixtures/GameChannelStoragePropFactory' import PlayerFactory from '../../../fixtures/PlayerFactory' @@ -22,8 +23,18 @@ describe('Game channel API - list storage', () => { .state(() => ({ key: 'key2' })) .one() await em.persist([prop1, prop2]).flush() - await prop1.persistToRedis(redis) - await prop2.persistToRedis(redis) + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: prop1.key, + props: [prop1], + }) + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: prop2.key, + props: [prop2], + }) const res = await request(app) .get(`/v1/game-channels/${channel.id}/storage/list`) @@ -85,7 +96,12 @@ describe('Game channel API - list storage', () => { .one() await em.persist([channel, player, cachedProp, dbProp]).flush() - await cachedProp.persistToRedis(redis) + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: cachedProp.key, + props: [cachedProp], + }) const res = await request(app) .get(`/v1/game-channels/${channel.id}/storage/list`) @@ -142,6 +158,92 @@ describe('Game channel API - list storage', () => { expect(res.body.props[0].key).toBe(existingProp.key) }) + it('should return all rows for prop array keys', 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.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + + const arrayProp1 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const arrayProp2 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'shield', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const scalarProp = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'score', + value: '42', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + await em.persist([channel, player, arrayProp1, arrayProp2, scalarProp]).flush() + await GameChannelStorageProp.persistToRedis({ + redis, + channelId: channel.id, + key: scalarProp.key, + props: [scalarProp], + }) + + const res = await request(app) + .get(`/v1/game-channels/${channel.id}/storage/list`) + .query({ propKeys: ['score', 'items[]'] }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.props).toHaveLength(2) + + const arrayResult = res.body.props.find((p: { key: string }) => p.key === 'items[]') + expect(JSON.parse(arrayResult.value).sort()).toStrictEqual(['shield', 'sword']) + + const scalarResult = res.body.props.find((p: { key: string }) => p.key === 'score') + expect(scalarResult.value).toBe('42') + }) + + it('should return a single-element array prop as a JSON array', 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.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + + const prop = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + await em.persist([channel, player, prop]).flush() + + const res = await request(app) + .get(`/v1/game-channels/${channel.id}/storage/list`) + .query({ propKeys: ['items[]'] }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.props).toHaveLength(1) + expect(res.body.props[0].key).toBe('items[]') + expect(JSON.parse(res.body.props[0].value)).toStrictEqual(['sword']) + }) + it('should reject requests with too many keys', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) diff --git a/tests/routes/api/game-channel/put-storage.test.ts b/tests/routes/api/game-channel/put-storage.test.ts index 44a49006..ca82284f 100644 --- a/tests/routes/api/game-channel/put-storage.test.ts +++ b/tests/routes/api/game-channel/put-storage.test.ts @@ -315,6 +315,311 @@ describe('Game channel API - update storage', () => { expect(res.body).toStrictEqual({ message: 'Player not found' }) }) + it('should deduplicate prop array values', 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 em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'sword' }, + { key: 'items[]', value: 'sword' }, + { key: 'items[]', value: 'shield' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(2) + expect(res.body.failedProps).toHaveLength(0) + + const props = await em.getRepository(GameChannelStorageProp).find({ gameChannel: channel }) + expect(props).toHaveLength(2) + expect(props.map((p) => p.value).sort()).toStrictEqual(['shield', 'sword']) + }) + + it('should create new array storage props', 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 em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'sword' }, + { key: 'items[]', value: 'shield' }, + { key: 'items[]', value: 'potion' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(3) + expect(res.body.deletedProps).toHaveLength(0) + expect(res.body.failedProps).toHaveLength(0) + + const props = await em.getRepository(GameChannelStorageProp).find({ gameChannel: channel }) + expect(props).toHaveLength(3) + expect(props.map((p) => p.value).sort()).toStrictEqual(['potion', 'shield', 'sword']) + }) + + it('should replace existing array props entirely when new values are provided', 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]) + + const existingProp1 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const existingProp2 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'shield', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + + await em.persist([channel, existingProp1, existingProp2]).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'bow' }, + { key: 'items[]', value: 'arrow' }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(2) + expect(res.body.deletedProps).toHaveLength(2) + expect(res.body.failedProps).toHaveLength(0) + + const props = await em.getRepository(GameChannelStorageProp).find({ gameChannel: channel }) + expect(props).toHaveLength(2) + expect(props.map((p) => p.value).sort()).toStrictEqual(['arrow', 'bow']) + }) + + it('should preserve createdBy and createdAt for retained array prop values', 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() + const otherPlayer = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + + const existingProp = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: otherPlayer.aliases[0], + lastUpdatedBy: otherPlayer.aliases[0], + })) + .one() + + await em.persist([channel, existingProp]).flush() + const originalCreatedAt = existingProp.createdAt + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'sword' }, // existing value — should be preserved + { key: 'items[]', value: 'shield' }, // new value + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(2) + expect(res.body.deletedProps).toHaveLength(0) + expect(res.body.failedProps).toHaveLength(0) + + await em.refresh(existingProp) + expect(existingProp.createdBy.id).toBe(otherPlayer.aliases[0].id) + expect(existingProp.createdAt.getTime()).toBeCloseTo(originalCreatedAt.getTime(), -3) + expect(existingProp.lastUpdatedBy.id).toBe(player.aliases[0].id) + }) + + it('should use the original creator for new values added to an existing array', 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() + const originalCreator = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + + const existingProp = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: originalCreator.aliases[0], + lastUpdatedBy: originalCreator.aliases[0], + })) + .one() + await em.persist([channel, existingProp]).flush() + + await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'sword' }, + { key: 'items[]', value: 'shield' }, // new value + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + const newProp = await em + .repo(GameChannelStorageProp) + .findOne({ gameChannel: channel, key: 'items[]', value: 'shield' }) + expect(newProp!.createdBy.id).toBe(originalCreator.aliases[0].id) + expect(newProp!.lastUpdatedBy.id).toBe(player.aliases[0].id) + }) + + it('should delete all array prop rows when all values are null', 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]) + + const existingProp1 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'sword', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + const existingProp2 = await new GameChannelStoragePropFactory(channel) + .state(() => ({ + key: 'items[]', + value: 'shield', + createdBy: player.aliases[0], + lastUpdatedBy: player.aliases[0], + })) + .one() + + await em.persist([channel, existingProp1, existingProp2]).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [{ key: 'items[]', value: null }], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.upsertedProps).toHaveLength(0) + expect(res.body.deletedProps).toHaveLength(2) + expect(res.body.failedProps).toHaveLength(0) + + const props = await em + .getRepository(GameChannelStorageProp) + .find({ gameChannel: channel, key: 'items[]' }) + expect(props).toHaveLength(0) + }) + + it('should reject array props where the key exceeds the max length', 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 em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [{ key: `${randText({ charCount: 127 })}[]`, value: 'value' }], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.failedProps).toHaveLength(1) + expect(res.body.failedProps[0].error).toMatch(/exceeds 128 characters/) + }) + + it('should reject array props where the array length exceeds the maximum', 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 em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: Array.from({ length: 1001 }, (_, i) => ({ key: 'items[]', value: String(i) })), + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.failedProps).toHaveLength(1) + expect(res.body.failedProps[0].key).toBe('items[]') + expect(res.body.failedProps[0].error).toBe( + `Prop array length (1001) for key 'items[]' exceeds 1000 items`, + ) + }) + + it('should reject array props where a value exceeds the max length', 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 em.persist(channel).flush() + + const res = await request(app) + .put(`/v1/game-channels/${channel.id}/storage`) + .send({ + props: [ + { key: 'items[]', value: 'valid' }, + { key: 'items[]', value: randText({ charCount: 513 }) }, + ], + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.failedProps).toHaveLength(1) + expect(res.body.failedProps[0].key).toBe('items[]') + expect(res.body.failedProps[0].error).toBe('Prop value length (513) exceeds 512 characters') + // no rows should have been created since the whole key failed + const props = await em + .getRepository(GameChannelStorageProp) + .find({ gameChannel: channel, key: 'items[]' }) + expect(props).toHaveLength(0) + }) + it('should accept an empty array of props', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) const channel = await new GameChannelFactory(apiKey.game).one()