diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index f62cc6d..b36892b 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -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 { +export async function getMyInventions( + db: D1Database, + playerId: number +): Promise { 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() 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 & 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 { + 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() + 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 { + 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 diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 118336c..02db2f5 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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'), diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 90fa487..4083657 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -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({ 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({ 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({ 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({ 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 | 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. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 24c7e36..9e8eaf5 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 => + ( + (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 => + (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 => + 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 => { + 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 => + 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 => + (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',