mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] fix key images
This commit is contained in:
+128
-5
@@ -144,6 +144,7 @@ import {
|
||||
UpdateObjectiveRequest,
|
||||
UpdateObjectiveResponse,
|
||||
UpdateRoomCurrencyRequest,
|
||||
UpdateRoomKeyRequest,
|
||||
UpsertRoomConsumableRequest,
|
||||
} from './openapi'
|
||||
import { claimReward, isNewActivity } from './reward-db'
|
||||
@@ -162,7 +163,14 @@ import {
|
||||
getRoomCurrency,
|
||||
updateRoomCurrency,
|
||||
} from './room-currency-db'
|
||||
import { awardRoomKey, createRoomKey, getRoomKeys, ownsRoomKeys } from './room-key-db'
|
||||
import {
|
||||
awardRoomKey,
|
||||
createRoomKey,
|
||||
getRoomKey,
|
||||
getRoomKeys,
|
||||
ownsRoomKeys,
|
||||
updateRoomKey,
|
||||
} from './room-key-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
@@ -295,6 +303,14 @@ const ROOM_KEY_STATUS_FAILED = 1
|
||||
*/
|
||||
const MAX_KEYS_PER_ROOM = 10
|
||||
|
||||
/**
|
||||
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
|
||||
* are real money to the player paying, so they are capped low; a room's own currency is the
|
||||
* room's to inflate.
|
||||
*/
|
||||
const MAX_KEY_PRICE_TOKENS = 1000
|
||||
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
|
||||
|
||||
/**
|
||||
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
||||
*
|
||||
@@ -5565,12 +5581,13 @@ const app = new Hono<App>({ strict: false })
|
||||
'',
|
||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
|
||||
'the `api` config tells the client — and the eleventh is refused.',
|
||||
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
|
||||
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
|
||||
'',
|
||||
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
|
||||
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
||||
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
|
||||
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||||
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||||
'the auth gates answer with an HTTP status of their own.',
|
||||
'',
|
||||
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
|
||||
@@ -5599,6 +5616,10 @@ const app = new Hono<App>({ strict: false })
|
||||
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
|
||||
const name = str(body.Name).trim()
|
||||
if (name === '') return roomKeyEnvelope(c, null)
|
||||
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
|
||||
// says otherwise, more than the token cap.
|
||||
const price = Math.max(0, int(body.Price, 0))
|
||||
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||
@@ -5613,8 +5634,7 @@ const app = new Hono<App>({ strict: false })
|
||||
Type: str(body.Type).trim() || 'Key',
|
||||
Name: censorSwears(name),
|
||||
Description: censorSwears(str(body.Description)),
|
||||
// A key cannot cost less than nothing.
|
||||
Price: Math.max(0, int(body.Price, 0)),
|
||||
Price: price,
|
||||
})
|
||||
|
||||
await pushRoomKeyCreated(c, key, accountId)
|
||||
@@ -5622,6 +5642,109 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
|
||||
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
|
||||
// comes off the KEY, never the request.
|
||||
.put(
|
||||
'/api/roomkeys/v1/updateAll',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Update a room key',
|
||||
description: [
|
||||
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
|
||||
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
|
||||
'',
|
||||
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
|
||||
'from anyone else is a 403.',
|
||||
'',
|
||||
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
|
||||
'null) — that is how a key on a room currency is taken back off it; otherwise it',
|
||||
'must be a `room_currency` id belonging to the key’s own room.',
|
||||
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
|
||||
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
|
||||
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
|
||||
'a room currency. A field left out of the body is left alone rather than reset, and',
|
||||
'the cap is checked against the resulting pair.',
|
||||
'',
|
||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
|
||||
'',
|
||||
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
|
||||
'— an ASSUMPTION: what the client reads from this response has not been observed.',
|
||||
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
|
||||
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
|
||||
'`RoomKey`.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
|
||||
responses: {
|
||||
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
|
||||
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
|
||||
|
||||
const existing = await getRoomKey(c.env.DB, roomKeyId)
|
||||
if (!existing) return roomKeyEnvelope(c, null)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
|
||||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||
if (!canManage) return c.body(null, 403)
|
||||
|
||||
// A name sent but blank is a bad edit, not an instruction to leave it alone.
|
||||
const name = str(body.Name)?.trim()
|
||||
if (name === '') return roomKeyEnvelope(c, null)
|
||||
|
||||
let price = existing.Price
|
||||
if (str(body.Price) !== undefined) {
|
||||
price = Number.parseInt(str(body.Price)!, 10)
|
||||
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
|
||||
}
|
||||
|
||||
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
|
||||
// absence means "leave alone".
|
||||
let purchaseCurrencyId = existing.PurchaseCurrencyId
|
||||
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
|
||||
if (postedCurrencyId === '') {
|
||||
purchaseCurrencyId = null
|
||||
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
|
||||
// A key is sold in its own room's money, not another's.
|
||||
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
|
||||
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
|
||||
purchaseCurrencyId = currency.CurrencyId
|
||||
}
|
||||
|
||||
const maxPrice =
|
||||
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
|
||||
if (price > maxPrice) return roomKeyEnvelope(c, null)
|
||||
|
||||
// Blank is a value here too: no art. Only absence leaves the stored name alone.
|
||||
const postedImageName = str(body.ImageName)?.trim()
|
||||
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
|
||||
|
||||
const description = str(body.Description)
|
||||
return roomKeyEnvelope(
|
||||
c,
|
||||
await updateRoomKey(c.env.DB, roomKeyId, {
|
||||
Name: name === undefined ? existing.Name : censorSwears(name),
|
||||
Description: description === undefined ? existing.Description : censorSwears(description),
|
||||
Price: price,
|
||||
PurchaseCurrencyId: purchaseCurrencyId,
|
||||
ImageName: imageName,
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
|
||||
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
|
||||
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
|
||||
|
||||
@@ -645,9 +645,9 @@ export const RoomKeyDto = z.object({
|
||||
PurchaseCurrencyId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('A `room_currency` id, or null — the create body names none'),
|
||||
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
|
||||
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
|
||||
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
|
||||
})
|
||||
|
||||
@@ -672,7 +672,37 @@ export const CreateRoomKeyRequest = z.object({
|
||||
RoomId: z.string().describe('The room the key opens'),
|
||||
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
|
||||
Description: z.string().optional().describe('Defaults to empty'),
|
||||
Price: z.string().optional().describe('What the key costs; defaults to 0'),
|
||||
Price: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
|
||||
* names one key and carries every editable field of it
|
||||
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
|
||||
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
|
||||
* instruction to clear it; only a field absent from the body is left alone.
|
||||
*/
|
||||
export const UpdateRoomKeyRequest = z.object({
|
||||
RoomKeyId: z.string().describe('The key to edit'),
|
||||
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||
Price: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
|
||||
PurchaseCurrencyId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
|
||||
),
|
||||
ImageName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* a player by being awarded. The endpoints:
|
||||
* - `POST /api/roomkeys/v1/create` lists one
|
||||
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
|
||||
* - `PUT /api/roomkeys/v1/updateAll` edits one
|
||||
* - `POST /api/roomkeys/v1/awardbulk` awards the caller some
|
||||
* - `POST /api/roomkeys/v1/owns/bulk` says who holds which
|
||||
*
|
||||
@@ -24,8 +25,8 @@ export const ROOM_KEY_SCHEMA_DDL: string[] = [
|
||||
// back as the ORDINAL (`Type: 0`) — see {@link ROOM_KEY_TYPE_ORDINALS}. Only `Key` has
|
||||
// been observed.
|
||||
//
|
||||
// `purchase_currency_id` is the `room_currency` the key is charged in — nullable, and the
|
||||
// create body names none. `image_name` is '' in the row (NOT NULL), but the client reads
|
||||
// `purchase_currency_id` is the `room_currency` the key is charged in — null means tokens,
|
||||
// which is what a new key costs: the create body names none, and an edit can set it. `image_name` is '' in the row (NOT NULL), but the client reads
|
||||
// `ImageName` as null until a key carries art, so an empty column is served as null.
|
||||
`CREATE TABLE IF NOT EXISTS room_key (
|
||||
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -76,11 +77,11 @@ export interface RoomKey {
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
/** A `room_currency` id, or null — the create body names none. */
|
||||
/** A `room_currency` id, or null for tokens — which is what a new key is priced in. */
|
||||
PurchaseCurrencyId: string | null
|
||||
/** ISO-8601 UTC. */
|
||||
CreatedAt: string
|
||||
/** Null until a key can carry art. */
|
||||
/** Null until an edit gives the key art. */
|
||||
ImageName: string | null
|
||||
/** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */
|
||||
Type: number
|
||||
@@ -147,6 +148,56 @@ export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<Ro
|
||||
return toRoomKey(row!)
|
||||
}
|
||||
|
||||
/** One key by its id, or null when there is no such row. */
|
||||
export async function getRoomKey(db: D1Database, roomKeyId: number): Promise<RoomKey | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_key_id = ?1`)
|
||||
.bind(roomKeyId)
|
||||
.first<RoomKeyRow>()
|
||||
|
||||
return row ? toRoomKey(row) : null
|
||||
}
|
||||
|
||||
/** What an edit may change — the rest of a key (its ids, room, type, `CreatedAt`) is fixed. */
|
||||
export interface RoomKeyChanges {
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
/** A `room_currency` id, or null for tokens. */
|
||||
PurchaseCurrencyId: string | null
|
||||
/** The key's art, or null for none — stored as '' (the column is NOT NULL). */
|
||||
ImageName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a key's editable fields, returning it as it now stands — null when `roomKeyId`
|
||||
* names no listed key.
|
||||
*/
|
||||
export async function updateRoomKey(
|
||||
db: D1Database,
|
||||
roomKeyId: number,
|
||||
changes: RoomKeyChanges
|
||||
): Promise<RoomKey | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE room_key
|
||||
SET name = ?2, description = ?3, price = ?4, purchase_currency_id = ?5, image_name = ?6
|
||||
WHERE room_key_id = ?1
|
||||
RETURNING ${SELECT_COLUMNS}`
|
||||
)
|
||||
.bind(
|
||||
roomKeyId,
|
||||
changes.Name,
|
||||
changes.Description,
|
||||
changes.Price,
|
||||
changes.PurchaseCurrencyId,
|
||||
changes.ImageName ?? ''
|
||||
)
|
||||
.first<RoomKeyRow>()
|
||||
|
||||
return row ? toRoomKey(row) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key a room has listed, oldest first — the order its owner built them up in. The
|
||||
* autoincrement id IS creation order, so it is the sort key.
|
||||
|
||||
@@ -2241,6 +2241,180 @@ describe('econ endpoints', () => {
|
||||
expect((await keysOf(2511)).length).toBe(10)
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
})
|
||||
|
||||
test('POST create refuses a token price over 1000', async () => {
|
||||
const atCap = await create({ ...body, Price: '1000' }, await bearer('1'))
|
||||
expect((await envOf(atCap)).Status).toBe(0)
|
||||
const over = await create({ ...body, Price: '1001' }, await bearer('1'))
|
||||
expect(await envOf(over)).toEqual({ Status: 1, RoomKey: null })
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
})
|
||||
|
||||
test('PUT updateAll edits one key, capping the price by its currency', async () => {
|
||||
const update = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
const mint = async (roomId: string, as: string) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer(as)),
|
||||
},
|
||||
body: new URLSearchParams({ RoomId: roomId, Name: 'coin' }),
|
||||
})
|
||||
).json()) as { Value: { CurrencyId: string } }
|
||||
).Value.CurrencyId
|
||||
|
||||
const key = await keyOf(await create(body, await bearer('1')))
|
||||
const other = await keyOf(await create({ ...body, Name: 'other' }, await bearer('1')))
|
||||
const id = String(key.RoomKeyId)
|
||||
// The body exactly as the client puts it.
|
||||
const edit = {
|
||||
RoomKeyId: id,
|
||||
Name: 'key1',
|
||||
Description: 'testssdfsdfsdf',
|
||||
Price: '1000',
|
||||
PurchaseCurrencyId: '',
|
||||
}
|
||||
const refused = { Status: 1, RoomKey: null }
|
||||
|
||||
expect((await update(edit)).status).toBe(401)
|
||||
expect((await update(edit, await bearer('42'))).status).toBe(403)
|
||||
|
||||
// In tokens: 1000 passes, 1001 does not. A co-owner may edit.
|
||||
const res = await update(edit, await bearer('2'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await envOf(res)).toEqual({
|
||||
Status: 0,
|
||||
RoomKey: { ...key, Name: 'key1', Description: 'testssdfsdfsdf', Price: 1000 },
|
||||
})
|
||||
expect(await envOf(await update({ ...edit, Price: '1001' }, await bearer('1')))).toEqual(
|
||||
refused
|
||||
)
|
||||
|
||||
// In the room's own currency the cap is a billion.
|
||||
const currencyId = await mint('2511', '1')
|
||||
const rich = { ...edit, Price: '1000000000', PurchaseCurrencyId: currencyId }
|
||||
expect((await envOf(await update(rich, await bearer('1')))).RoomKey).toMatchObject({
|
||||
Price: 1000000000,
|
||||
PurchaseCurrencyId: currencyId,
|
||||
})
|
||||
expect(
|
||||
await envOf(await update({ ...rich, Price: '1000000001' }, await bearer('1')))
|
||||
).toEqual(refused)
|
||||
// Back to tokens with the big price still on it: the token cap applies again.
|
||||
expect(
|
||||
await envOf(await update({ RoomKeyId: id, PurchaseCurrencyId: '' }, await bearer('1')))
|
||||
).toEqual(refused)
|
||||
|
||||
// Refusals: no such key, a blank name, an unknown currency.
|
||||
for (const bad of [
|
||||
{ ...edit, RoomKeyId: '999999' },
|
||||
{ ...edit, Name: ' ' },
|
||||
{ ...edit, PurchaseCurrencyId: '46eddc63-e6d4-42cc-bf04-5d879e33ac17' },
|
||||
]) {
|
||||
expect(await envOf(await update(bad, await bearer('1')))).toEqual(refused)
|
||||
}
|
||||
|
||||
// Only the named key moved, and the refused edits left it as the last good one.
|
||||
expect(await keysOf(2511)).toEqual([
|
||||
{
|
||||
...key,
|
||||
Name: 'key1',
|
||||
Description: 'testssdfsdfsdf',
|
||||
Price: 1000000000,
|
||||
PurchaseCurrencyId: currencyId,
|
||||
},
|
||||
other,
|
||||
])
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
|
||||
.bind(currencyId)
|
||||
.run()
|
||||
})
|
||||
|
||||
test('PUT updateAll: a BLANK PurchaseCurrencyId or ImageName clears it; an absent one is left alone', async () => {
|
||||
const update = async (fields: Record<string, string>) =>
|
||||
envOf(
|
||||
await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/updateAll`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer('1')),
|
||||
},
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
)
|
||||
const stored = async (roomKeyId: number) =>
|
||||
env.DB.prepare(
|
||||
'SELECT purchase_currency_id, image_name FROM room_key WHERE room_key_id = ?1'
|
||||
)
|
||||
.bind(roomKeyId)
|
||||
.first<{ purchase_currency_id: string | null; image_name: string }>()
|
||||
|
||||
const currencyId = (
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer('1')),
|
||||
},
|
||||
body: new URLSearchParams({ RoomId: '2511', Name: 'coin' }),
|
||||
})
|
||||
).json()) as { Value: { CurrencyId: string } }
|
||||
).Value.CurrencyId
|
||||
|
||||
const key = await keyOf(await create(body, await bearer('1')))
|
||||
const id = String(key.RoomKeyId)
|
||||
expect(key).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
|
||||
|
||||
// Put the key on the room's currency and give it art.
|
||||
const set = await update({
|
||||
RoomKeyId: id,
|
||||
PurchaseCurrencyId: currencyId,
|
||||
ImageName: 'key.png',
|
||||
})
|
||||
expect(set.RoomKey).toMatchObject({ PurchaseCurrencyId: currencyId, ImageName: 'key.png' })
|
||||
expect(await stored(key.RoomKeyId)).toEqual({
|
||||
purchase_currency_id: currencyId,
|
||||
image_name: 'key.png',
|
||||
})
|
||||
|
||||
// An edit that mentions neither leaves both alone.
|
||||
const renamed = await update({ RoomKeyId: id, Name: 'renamed' })
|
||||
expect(renamed.RoomKey).toMatchObject({
|
||||
Name: 'renamed',
|
||||
PurchaseCurrencyId: currencyId,
|
||||
ImageName: 'key.png',
|
||||
})
|
||||
|
||||
// Blank clears the currency — NULL in the row, null on the wire — and nothing else.
|
||||
const noCurrency = await update({ RoomKeyId: id, PurchaseCurrencyId: '' })
|
||||
expect(noCurrency.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: 'key.png' })
|
||||
expect(await stored(key.RoomKeyId)).toEqual({
|
||||
purchase_currency_id: null,
|
||||
image_name: 'key.png',
|
||||
})
|
||||
|
||||
// Blank clears the art: '' in the row, served as null. The list read agrees.
|
||||
const noArt = await update({ RoomKeyId: id, ImageName: '' })
|
||||
expect(noArt.RoomKey).toMatchObject({ PurchaseCurrencyId: null, ImageName: null })
|
||||
expect(await stored(key.RoomKeyId)).toEqual({ purchase_currency_id: null, image_name: '' })
|
||||
expect(await keysOf(2511)).toEqual([noArt.RoomKey])
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_key').run()
|
||||
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id = ?1')
|
||||
.bind(currencyId)
|
||||
.run()
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
|
||||
@@ -6073,6 +6247,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
'PUT /api/equipment/v1/update',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||
'PUT /api/roomkeys/v1/updateAll',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
|
||||
Reference in New Issue
Block a user