mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
free invention purchase
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
|
||||
-- inventions a player has bought from the invention store. Written at purchase time by
|
||||
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
|
||||
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
|
||||
-- second purchase is a no-op rather than a duplicate row.
|
||||
--
|
||||
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
|
||||
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
|
||||
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
|
||||
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
|
||||
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_invention (
|
||||
account_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, invention_id)
|
||||
);
|
||||
+117
-1
@@ -6,6 +6,10 @@ import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||
// their own, and buyInvention has to read the very rows `api` writes.
|
||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
@@ -17,6 +21,7 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
isSpendable,
|
||||
@@ -30,10 +35,12 @@ import {
|
||||
} from './consumables-db'
|
||||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||
import { getInventory, grantItem } from './inventory-db'
|
||||
import { grantInvention, ownsInvention } from './inventory-invention-db'
|
||||
import {
|
||||
AUTHED,
|
||||
AvatarV2Dto,
|
||||
BalanceEntry,
|
||||
BuyInventionResponse,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
ChallengeProgressRequest,
|
||||
@@ -69,7 +76,8 @@ import type { Outfit } from './outfit-db'
|
||||
/**
|
||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||
* inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars and gift boxes are D1-backed;
|
||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||
*
|
||||
@@ -1164,6 +1172,114 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
|
||||
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
|
||||
//
|
||||
// Only FREE inventions can be bought for now: we look the invention up by id and
|
||||
// confirm its stored `Price` — both against the price the client rendered (a
|
||||
// mismatch is a stale or tampered client, 409) and against 0 (a priced invention is
|
||||
// 402, since nothing here debits the buyer or pays the creator yet). That keeps the
|
||||
// path from ever moving currency while the payout half is unimplemented.
|
||||
//
|
||||
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
||||
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
||||
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
|
||||
// bumped: that column lives on the `invention` table the `api` worker owns, and this
|
||||
// worker only reads it.
|
||||
.get(
|
||||
'/api/storefronts/v2/buyInvention',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy an invention',
|
||||
description: [
|
||||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
||||
'its stored `Price`, records ownership in `inventory_invention`, and returns the',
|
||||
'invention alongside the (unchanged) balance. Only FREE inventions are sellable for',
|
||||
'now — a priced one is 402. A GET because that is how the client sends it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'inventionId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Invention id; missing or non-numeric is 400',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'requestedPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
||||
400: json(ErrorResponse, 'Missing/non-numeric inventionId, or buying your own'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
402: json(ErrorResponse, 'The invention is not free (unsupported for now)'),
|
||||
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
|
||||
404: json(ErrorResponse, 'No such invention'),
|
||||
409: json(ErrorResponse, 'Already owned, or the price has changed'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
// Absent/non-numeric requestedPrice reads as 0 — the only price we sell at anyway,
|
||||
// so the confirmation below still has something to compare against.
|
||||
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
||||
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
||||
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
||||
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
||||
if (invention.CreatorPlayerId === id) {
|
||||
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
||||
}
|
||||
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
||||
return c.json({ error: 'Already owned' }, 409)
|
||||
}
|
||||
|
||||
// Confirm the price twice: against what the client rendered, then against the only
|
||||
// price we can actually settle (free).
|
||||
if (invention.Price !== requestedPrice) {
|
||||
return c.json({ error: 'Price has changed' }, 409)
|
||||
}
|
||||
if (invention.Price !== 0) {
|
||||
return c.json({ error: 'Only free inventions can be bought right now' }, 402)
|
||||
}
|
||||
|
||||
await grantInvention(c.env.DB, id, inventionId)
|
||||
|
||||
// Nothing was debited, so this is the buyer's balance as it stands (a first read
|
||||
// seeds their starting grant, as everywhere else). Unlike buyItem — whose `Balance`
|
||||
// is the change applied — the reference server answers this one with the RESULTING
|
||||
// total, so no StorefrontBalanceUpdate push is needed either: nothing changed.
|
||||
const balance = await getBalance(
|
||||
c.env.DB,
|
||||
id,
|
||||
CurrencyType.RecCenterTokens,
|
||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
)
|
||||
return c.json({
|
||||
BalanceUpdateResponse: {
|
||||
Balance: balance,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
||||
},
|
||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
||||
// save/read endpoints serve — the client re-renders the invention from it.
|
||||
InventionResponse: toSaveResult(invention),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||
// placeholder banner with no purchasable items until real promo data exists.
|
||||
.get(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
||||
* bought. One row per (account, invention), written at purchase time by
|
||||
* `GET /api/storefronts/v2/buyInvention`.
|
||||
*
|
||||
* Only the invention id is stored: the invention record itself lives in the `invention`
|
||||
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
||||
* this same database, and copying its DTO here would leave two rows to keep in step. A
|
||||
* creator is not listed here either — they own their invention through its
|
||||
* `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator.
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||
* 0008_inventory_invention.sql.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */
|
||||
export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS inventory_invention (
|
||||
account_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, invention_id)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary
|
||||
* key: owning an invention is boolean, so a second grant keeps the original
|
||||
* `acquired_at` rather than back-dating the purchase to now.
|
||||
*/
|
||||
export async function grantInvention(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
inventionId: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
.bind(accountId, inventionId, new Date().toISOString())
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player has bought an invention. This answers for BOUGHT inventions only —
|
||||
* the creator of an invention owns it without a row here, so callers that mean "may use
|
||||
* this invention" must check `CreatorPlayerId` as well.
|
||||
*/
|
||||
export async function ownsInvention(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
inventionId: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT 1 AS owned FROM inventory_invention WHERE account_id = ?1 AND invention_id = ?2'
|
||||
)
|
||||
.bind(accountId, inventionId)
|
||||
.first<{ owned: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/** The ids of every invention a player has bought, oldest purchase first. */
|
||||
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT invention_id FROM inventory_invention WHERE account_id = ?1 ORDER BY acquired_at, invention_id'
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<{ invention_id: number }>()
|
||||
return results.map((r) => r.invention_id)
|
||||
}
|
||||
@@ -130,7 +130,34 @@ export const BuyItemResponse = z.object({
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
})
|
||||
|
||||
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
||||
/**
|
||||
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
|
||||
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
|
||||
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
|
||||
* and the invention envelope the invention endpoints already serve.
|
||||
*/
|
||||
export const BuyInventionResponse = z.object({
|
||||
BalanceUpdateResponse: z.object({
|
||||
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||
BalanceUpdates: z.array(
|
||||
z.object({
|
||||
UpdateResponse: z.int(),
|
||||
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
|
||||
})
|
||||
),
|
||||
}),
|
||||
InventionResponse: z
|
||||
.object({
|
||||
Status: z.int(),
|
||||
Invention: JsonObject,
|
||||
InventionVersion: JsonObject,
|
||||
})
|
||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
||||
})
|
||||
|
||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/402/403/404/409. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,9 @@ import '../../econ.app'
|
||||
|
||||
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
||||
// is built here too (see the same cross-worker import in econ.app.ts).
|
||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||
import { SCHEMA_DDL } from '../../avatar-db'
|
||||
import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { getOwnedInventionIds, INVENTORY_INVENTION_SCHEMA_DDL } from '../../inventory-invention-db'
|
||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
@@ -39,11 +43,76 @@ beforeAll(async () => {
|
||||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||
.run()
|
||||
for (const invention of SEEDED_INVENTIONS) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(invention))
|
||||
.run()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
|
||||
* reads are meaningful — id, creator, published flag and price — but the record is
|
||||
* shaped like a real stored `RRInvention` so the response envelope is realistic.
|
||||
*/
|
||||
function invention(
|
||||
inventionId: number,
|
||||
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
|
||||
) {
|
||||
return {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: `replication-${inventionId}`,
|
||||
CreatorPlayerId: 999,
|
||||
Name: `Invention ${inventionId}`,
|
||||
Description: 'A test invention',
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: `version-${inventionId}`,
|
||||
VersionNumber: 1,
|
||||
BlobName: `invention-${inventionId}.inv`,
|
||||
BlobHash: null,
|
||||
InstantiationCost: 0,
|
||||
LightsCost: 0,
|
||||
ChipsCost: 0,
|
||||
CloudVariablesCost: 0,
|
||||
AICost: 0,
|
||||
},
|
||||
Accessibility: 0,
|
||||
IsPublished: true,
|
||||
IsFeatured: false,
|
||||
ModifiedAt: '2026-01-01T00:00:00.000Z',
|
||||
CreatedAt: '2026-01-01T00:00:00.000Z',
|
||||
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
|
||||
CreationRoomId: 0,
|
||||
NumPlayersHaveUsedInRoom: 0,
|
||||
NumDownloads: 0,
|
||||
CheerCount: 0,
|
||||
CreatorPermission: 100,
|
||||
GeneralPermission: 20,
|
||||
IsAGInvention: false,
|
||||
IsCertifiedInvention: false,
|
||||
Price: 0,
|
||||
AllowTrial: true,
|
||||
HideFromPlayer: false,
|
||||
ReferencedInventions: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const SEEDED_INVENTIONS = [
|
||||
invention(8), // free, published, someone else's — the sellable one
|
||||
invention(9, { Price: 250 }), // priced: not sellable while only free is supported
|
||||
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
|
||||
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
|
||||
]
|
||||
|
||||
/**
|
||||
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
||||
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
||||
@@ -920,6 +989,76 @@ describe('econ endpoints', () => {
|
||||
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||
})
|
||||
|
||||
// buyInvention is a GET with query params — that is how the client sends it.
|
||||
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
|
||||
const res = await buyInvention('50', 8)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
BalanceUpdateResponse: {
|
||||
Balance: number
|
||||
BalanceType: number
|
||||
CurrencyType: number
|
||||
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
|
||||
}
|
||||
InventionResponse: {
|
||||
Status: number
|
||||
Invention: { InventionId: number; Name: string }
|
||||
InventionVersion: { InventionId: number; VersionNumber: number }
|
||||
}
|
||||
}
|
||||
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
|
||||
// grant — not a change, unlike buyItem's.
|
||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
|
||||
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
|
||||
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
|
||||
expect(body.InventionResponse.Status).toBe(0)
|
||||
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
|
||||
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
|
||||
|
||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||
|
||||
// Owning an invention is boolean: buying it again is a conflict, not a second row.
|
||||
expect((await buyInvention('50', 8)).status).toBe(409)
|
||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention refuses anything but a free invention', async () => {
|
||||
// Invention 9 costs 250. Sending the price the client rendered is a 402 (nothing
|
||||
// here can settle a paid purchase yet); sending 0 for it is a stale/tampered price.
|
||||
expect((await buyInvention('51', 9, 250)).status).toBe(402)
|
||||
expect((await buyInvention('51', 9, 0)).status).toBe(409)
|
||||
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
|
||||
// Unpublished — a draft is not on sale, free or not.
|
||||
expect((await buyInvention('52', 10)).status).toBe(403)
|
||||
// Account 60 created invention 11; a creator already owns it.
|
||||
expect((await buyInvention('60', 11)).status).toBe(400)
|
||||
expect((await buyInvention('52', 9999)).status).toBe(404)
|
||||
// Missing/non-numeric inventionId.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
|
||||
headers: await bearer('52'),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
|
||||
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||
// Buy an item for account 24, then consume the box the way the client does: on the
|
||||
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
||||
@@ -1182,6 +1321,7 @@ describe('econ endpoints', () => {
|
||||
'GET /api/roomkeys/v1/mine',
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/storefronts/v1/adcarouselitems',
|
||||
'GET /api/storefronts/v2/buyInvention',
|
||||
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||
'GET /econ/customAvatarItems/v1/owned',
|
||||
|
||||
Reference in New Issue
Block a user