mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
[api] fix various issues with inventions
This commit is contained in:
+110
-21
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user