[api] fix various issues with inventions

This commit is contained in:
Devin Zuczek
2026-10-02 13:43:48 -04:00
parent 6167925ee0
commit a2f4e4959e
4 changed files with 357 additions and 69 deletions
+110 -21
View File
@@ -23,9 +23,10 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
* feed's query and `is_published`/`hide_from_player` most of the "is this in the store"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones);
* json_extract of a JSON `true` is 1, so those columns are 1/0 — and NULL when the key is
* missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
@@ -321,11 +322,20 @@ export function toInventionV9(invention: SavedInvention): InventionV9Dto {
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
// A record saved through `v6/save` stores no `UgcVersion`, and the client's byte
// defaults to 0 — a generation no client writes (every `v9/save` sends 1). The 2025
// client lists nothing that declares 0: these were on the wire and invisible.
UgcVersion: invention.UgcVersion ?? 1,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
// A published invention cannot be private. Everything published through
// `v3/publish` still stores the 0 a save mints (see INVENTION_ACCESSIBILITY), which
// this DTO — it has no `IsPublished` — would read as Private; serve it as Public.
Accessibility:
invention.IsPublished && invention.Accessibility === INVENTION_ACCESSIBILITY.private
? INVENTION_ACCESSIBILITY.public
: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
@@ -582,8 +592,19 @@ export async function getInventionsByCreator(
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*
* Each row is the stored record WITH the client's `RRInvention` laid over it
* ({@link toInventionV9}). The 2025 client decodes this list into that flat DTO, and a
* key the record lacks defaults silently: no `LatestVersionNumber` reads as a current
* version newer than the latest, no `UgcVersion` as generation 0 whatever was saved, and
* a published record's stored `Accessibility` 0 as Private. The stored keys that DTO has
* no member for (`CurrentVersion`, `IsPublished`, `Tags`, …) stay on the row: its decoder
* drops them, and the older client still reads them.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
export async function getMyInventions(
db: D1Database,
playerId: number
): Promise<ListedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
@@ -592,9 +613,51 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
return [...byId.values()]
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.map(withClientInvention)
}
/** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */
type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
function withClientInvention(invention: SavedInvention): ListedInvention {
return { ...invention, ...toInventionV9(invention) }
}
/** The most creators one `v1/fromcreators` call may name — D1 caps a statement's binds. */
const MAX_PORTFOLIO_CREATORS = 50
/**
* A creator's portfolio (`v1/fromcreators?id=…`) — the shelf on their profile, and the
* "from creators you follow" row. Newest first, paged in SQL via skip/take.
*
* Only PUBLISHED inventions (minus hidden ones), whoever asks — the creator included. A
* creator's drafts are on `v2/mine` and nowhere else; this is what they MADE and put out,
* not what they bought or are still working on. Unlisted ones ARE here: unlisted means
* "not in the store", and a portfolio is not the store.
*/
export async function getInventionsByCreators(
db: D1Database,
creatorPlayerIds: number[],
skip: number,
take: number
): Promise<ListedInvention[]> {
const creators = [...new Set(creatorPlayerIds)].slice(0, MAX_PORTFOLIO_CREATORS)
const limit = Math.max(take, 0)
if (creators.length === 0 || limit === 0) return []
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE creator_player_id IN (${creators.map((_, i) => `?${i + 3}`).join(', ')})
AND is_published = 1 AND hide_from_player = 0
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ?1 OFFSET ?2`
)
.bind(limit, Math.max(skip, 0), ...creators)
.all<InventionRow>()
return results.map((r) => withClientInvention(JSON.parse(r.data) as SavedInvention))
}
/**
@@ -858,17 +921,20 @@ export const INVENTION_PERMISSION = {
} as const
/**
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
* did — every record written before that endpoint carries 0, the value a save mints.
* Whether a PUBLISHED invention is in the store, which `v4/publish` sets and nothing before
* it did. The publish sheet sends one of two values, and both publish:
*
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
* an ordinary publish.
* - `public` (1) — published and in the store: search and the browse feeds list it.
* - `unlisted` (2) — published but NOT in the store. It is still published, so it is on the
* creator's portfolio (`v1/fromcreators`) and reachable by id; it just isn't for browsing.
*
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
* every invention published through `v3/publish` as private and empties the feeds.
* `private` (0) is what a save mints and what `v2/unpublish` puts back — the state of an
* invention that isn't published at all. Whether anyone else can see an invention is
* `IsPublished`; this field only decides, of the published ones, which the store shows.
*
* Every record published through the older `v3/publish` also still carries 0, since that
* endpoint names no accessibility. So the store filter excludes `unlisted` by name rather
* than requiring `public` — the latter would read all of those as private and drop them.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
@@ -877,9 +943,9 @@ export const INVENTION_ACCESSIBILITY = {
} as const
/**
* The "anyone may come across this" test the browse feeds and search share: published, not
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
* unlisted means — so the by-id reads deliberately don't apply it.
* The "is this in the store" test search and the browse feeds share: published, not hidden
* by moderation, and not unlisted. A creator's portfolio deliberately does NOT apply the
* last term (see `getInventionsByCreators`), nor do the by-id reads.
*
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
@@ -992,6 +1058,29 @@ export async function publishInvention(
return updated
}
/**
* Unpublish an invention (`v2/unpublish`) — back to what a save mints: not published, and
* `Accessibility` private, so the next publish sets both afresh. Buyers keep it (`v2/mine`
* lists a bought invention whatever its state), and `FirstPublishedAt` stays: it dates the
* FIRST publish, which still happened. The permission and price are left for the next
* publish to restate or keep. Returns the updated invention, or null when there's no such row.
*/
export async function unpublishInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
IsPublished: false,
Accessibility: INVENTION_ACCESSIBILITY.private,
}
await writeInvention(db, updated)
return updated
}
/**
* The most tokens an invention may sell for, inclusive. Enforced on every price a player
* can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the
+7 -2
View File
@@ -567,7 +567,7 @@ export const InventionV9Dto = z.object({
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
UgcVersion: z.int().describe('The UGC format the blob was written in; 1 when the save named none'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
@@ -760,7 +760,7 @@ export const PublishInventionRequest = z.object({
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
.describe('Public 1 publishes it into the store; Unlisted 2 publishes it but keeps it out of search and the feeds'),
Price: z
.int()
.nullable()
@@ -771,6 +771,11 @@ export const PublishInventionRequest = z.object({
),
})
/** `POST /api/inventions/v2/unpublish` JSON body — the id and nothing else. */
export const UnpublishInventionRequest = z.object({
InventionId: z.int().describe('The invention to unpublish; the caller must have created it'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
export const DeleteInventionRequest = z.object({
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
+79 -22
View File
@@ -34,6 +34,7 @@ import {
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreators,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
@@ -56,6 +57,7 @@ import {
setInventionTags,
toSaveResult,
toSaveResultV9,
unpublishInvention,
updateInvention,
} from '../inventions-db'
import {
@@ -83,6 +85,7 @@ import {
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionV9Dto,
InventionVersionDto,
json,
JsonArray,
@@ -106,6 +109,7 @@ import {
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UnpublishInventionRequest,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
@@ -1702,30 +1706,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
// portfolio, and the "from creators you follow" row, from.
//
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
// the client reads it as "this creator has published nothing" and renders an empty
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
// a filter on the invention table's creator column, the same feed shape as `toptoday`
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
// Published only, for every caller — a creator's own drafts are `v2/mine`'s alone.
.get(
'/api/inventions/v1/fromcreators',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by particular creators (stub)',
summary: 'Inventions by particular creators',
description:
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
'array for now, which the client renders as "nothing published" rather than as a ' +
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
parameters: [
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
'The inventions of the accounts named by `id` (repeatable), newest first — a ' +
'creator’s portfolio, and the "from creators you follow" row. Only PUBLISHED ' +
'inventions are listed, the caller’s own included: unpublished ones are served by ' +
'`v2/mine` alone. No `id` is an empty array.',
parameters: [intQuery('id', 'Creator account id; repeatable'), ...pageParams(100)],
responses: {
200: json(InventionDto.extend(InventionV9Dto.shape).array(), 'The creators’ inventions'),
},
}),
(c) => c.json([])
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByCreators(c.env.DB, inventionIdQuery(c), skip, take))
}
)
// Invention search/browse: published inventions matching `value` (matched against
@@ -1775,7 +1778,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The caller’s inventions'),
200: json(
InventionDto.extend(InventionV9Dto.shape).array(),
'The caller’s inventions — the stored record with the newer client’s flat DTO laid over it'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -2269,14 +2275,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
description:
'What puts an invention into search and the feeds. Creator only.\n\n' +
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says whether it ' +
'is in the store — 1 (Public) lists it in search and the feeds, 2 (Unlisted) ' +
'publishes it onto the creator’s portfolio and reachable by id but keeps it out ' +
'of both. A null `Price` leaves the price alone rather ' +
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
'publish in-band.\n\n' +
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
@@ -2327,6 +2334,56 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Unpublish an invention: `v4/publish` undone. Same body shape as `v2/delete` — the id
// and nothing else — and creator only.
//
// The reply is the `v9/save` envelope carrying the UPDATED invention, as `v4/publish`
// answers. That is an ASSUMPTION: what the client reads off this response has not been
// observed, only the request.
.post(
'/api/inventions/v2/unpublish',
describeRoute({
tags: ['Inventions'],
summary: 'Unpublish an invention',
description:
'Creator only. Takes the invention back out of search, the feeds and the ' +
'creator’s portfolio: `IsPublished` goes false and `Accessibility` back to 0, the ' +
'state a save mints, so the next `v4/publish` sets both afresh. Players who ' +
'already bought it keep it, the price and permission are left as they are, and ' +
'the first-publish date is not cleared.\n\n' +
'Answers the enveloped result `v4/publish` answers, carrying the unpublished ' +
'invention. Refusals are `Success: false` with a null `Value`.',
security: AUTHED,
requestBody: jsonBody(UnpublishInventionRequest, 'The invention to unpublish'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the unpublished invention under `Value`, or `Success: false` ' +
'with `Error` when it was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
const unpublished = await unpublishInvention(c.env.DB, gate.invention.InventionId)
if (unpublished === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(unpublished, unpublished.Tags ?? []))
}
)
// Delete an invention. The newer client's shape: a POST with a PascalCase body
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
// remove an invention is the account that made it, not a co-owner and not a buyer.
+161 -24
View File
@@ -2123,6 +2123,35 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/inventions/v2/mine serves a v3-published invention as public', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Published the old way', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('5149') }
)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints. The
// 2025 client has no `IsPublished` to read, only `Accessibility` — 0 is Private.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5149'),
})
expect((await mine.json()) as SavedInvention[]).toMatchObject([
{ InventionId: Invention.InventionId, IsPublished: true, Accessibility: 1 },
])
// Published, so it would otherwise turn up in the feeds the later tests count.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId }),
})
})
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
const body = {
name: '071126 13:10:50',
@@ -2192,6 +2221,19 @@ describe('public endpoints', () => {
expect(mine.status).toBe(200)
const list = (await mine.json()) as SavedInvention[]
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
// The 2025 client decodes this list into its flat `RRInvention`, so every key of
// that DTO is present even on a v6 record that stores none of them — and the keys
// the older client reads are still there beside them.
expect(list.find((i) => i.InventionId === saved.InventionId)).toMatchObject({
UgcVersion: 1,
LatestVersionNumber: 1,
ForceCannotPublish: false,
IsRecRoomApproved: false,
DisplayMetadataJson: null,
Accessibility: 0,
IsPublished: false,
CurrentVersion: { VersionNumber: 1 },
})
// The saved invention is fetchable by id via the v1 lookup.
const one = await exports.default.fetch(
@@ -2311,7 +2353,7 @@ describe('public endpoints', () => {
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Invention.UgcVersion).toBe(0)
expect(value.Invention.UgcVersion).toBe(1)
expect(value.Invention.DisplayMetadataJson).toBeNull()
// A save always mints a version; the key is nullable only because econ's
// `v3/buyInvention` answers in this same envelope and a buy mints none.
@@ -2795,7 +2837,7 @@ describe('public endpoints', () => {
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the store, and v2/unpublish undoes it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
@@ -2815,8 +2857,8 @@ describe('public endpoints', () => {
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.Accessibility).toBe(2)
// Unlisted is published — it is reachable by id, which is the whole point of it —
// but it is not something anyone comes across.
// Unlisted is published — on the creator's portfolio and reachable by id — but it is
// not in the store: search and the feeds leave it out.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
@@ -2830,6 +2872,39 @@ describe('public endpoints', () => {
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
const portfolio = async (): Promise<number[]> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5171`)
).json()) as SavedInvention[]
).map((i) => i.InventionId)
expect(await portfolio()).toEqual([inventionId])
// Unpublishing is creator only, and puts back what a save mints: not published,
// Accessibility 0 — off the portfolio, still on the creator's own shelf.
const unpublish = async (who: string): Promise<InventionSaveV9Result> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/unpublish`, {
method: 'POST',
headers: { ...(await bearer(who)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
).json()) as InventionSaveV9Result
expect((await unpublish('5199')).Success).toBe(false)
expect(await portfolio()).toEqual([inventionId])
const undone = await unpublish('5171')
expect(undone.Success).toBe(true)
expect(undone.Value?.Invention.Accessibility).toBe(0)
expect(await portfolio()).toEqual([])
const stored = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5171'),
})
).json()) as SavedInvention[]
const shelved = stored.find((i) => i.InventionId === inventionId)
expect(shelved).toMatchObject({ IsPublished: false, Accessibility: 0 })
expect(shelved?.FirstPublishedAt).not.toBeNull()
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
@@ -4277,24 +4352,87 @@ describe('public endpoints', () => {
expect(await price(cheap)).toBe(50)
})
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
// A stub: the client renders an empty array as "this creator has published nothing",
// where a 404 would read as a row that failed to load.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
test('GET /api/inventions/v1/fromcreators lists a creator’s public inventions', async () => {
const json = async (creator: string, path: string, body: unknown): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(creator)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const save = async (creator: string, name: string): Promise<number> => {
const res = await json(creator, '/api/inventions/v9/save', {
name,
inventionDataFilename: 'portfolio.inv',
ugcVersion: 1,
})
const id = ((await res.json()) as InventionSaveV9Result).Value?.Invention.InventionId
if (id === undefined) throw new Error('save failed')
return id
}
const publish = (creator: string, id: number, accessibility: number): Promise<Response> =>
json(creator, '/api/inventions/v4/publish', {
InventionId: id,
Permission: 20,
Accessibility: accessibility,
})
// The params are accepted and ignored, including a repeated `id` and none at all.
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
).json()
).toEqual([])
expect(
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
).toEqual([])
const draft = await save('5301', 'Portfolio Draft')
const listed = await save('5301', 'Portfolio Public')
const unlisted = await save('5301', 'Portfolio Unlisted')
const legacy = await save('5301', 'Portfolio Legacy')
const other = await save('5302', 'Other Creator')
await publish('5301', listed, 1)
await publish('5301', unlisted, 2)
await publish('5302', other, 1)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints.
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${legacy}`, {
headers: await bearer('5301'),
})
const shelf = async (query: string): Promise<SavedInvention[]> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?${query}`)
).json()) as SavedInvention[]
// Published is what lists an invention here, whatever its accessibility: the draft
// is not on the shelf, the Unlisted one is — and the `v3/publish` one, which stores
// the 0 a save mints, is served as Public.
const mine = await shelf('id=5301&skip=0&take=100')
expect(mine.map((i) => i.InventionId)).toEqual([legacy, unlisted, listed])
expect(mine.map((i) => i.Accessibility)).toEqual([1, 2, 1])
// The creator gets the same shelf as anyone: their drafts are `v2/mine`'s alone.
const own = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5301`, {
headers: await bearer('5301'),
})
).json()) as SavedInvention[]
expect(own.map((i) => i.InventionId)).not.toContain(draft)
// `id` repeats, newest first across creators, and skip/take page it.
expect((await shelf('id=5301&id=5302')).map((i) => i.InventionId)).toEqual([
other,
legacy,
unlisted,
listed,
])
expect((await shelf('id=5301&id=5302&skip=1&take=1')).map((i) => i.InventionId)).toEqual([
legacy,
])
// No creator, or one with nothing public, is an empty shelf rather than a 404.
expect(await shelf('')).toEqual([])
expect(await shelf('id=999999')).toEqual([])
// Published, so they would otherwise turn up in the feeds the later tests count.
for (const [creator, id] of [
['5301', listed],
['5301', unlisted],
['5301', legacy],
['5302', other],
] as const) {
await json(creator, '/api/inventions/v2/delete', { InventionId: id })
}
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
@@ -8738,9 +8876,7 @@ describe('player events', () => {
])
// Paging walks the sorted order, and an unknown sort is the default, not a 400.
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([
byAttendance[1],
])
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([byAttendance[1]])
const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity')
expect(unknown.status).toBe(200)
expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([
@@ -9924,6 +10060,7 @@ describe('openapi', () => {
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/unpublish',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',