mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[2025] unstable
This commit is contained in:
+88
-5
@@ -423,6 +423,86 @@ export const CustomAvatarItemsPage = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One custom-item save — the rebuilt version of a legacy avatar item. This is the
|
||||
* official shape, recorded for documentation: nothing stores custom items yet, so we
|
||||
* never actually emit one of these.
|
||||
*/
|
||||
export const CustomAvatarItemSave = z.object({
|
||||
customAvatarItemSaveId: z.int().describe('The save’s id'),
|
||||
customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
|
||||
unityAssetId: z.string().describe('Guid of the built Unity asset'),
|
||||
createdAt: z.string().describe('ISO 8601 timestamp'),
|
||||
thumbnailFileName: z.string(),
|
||||
additionalConfiguration: z.string(),
|
||||
unityAsset: z.string(),
|
||||
unityAssetHash: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
|
||||
* item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
|
||||
* the value shape is documented rather than served.
|
||||
*/
|
||||
export const LegacyAvatarItemSaves = z.object({
|
||||
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
|
||||
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
|
||||
* account form, where every field that would carry an outfit is null/empty and
|
||||
* `DataVersion` is 9.
|
||||
*/
|
||||
export const OutfitsMeResponse = z.object({
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
Selections: JsonArray,
|
||||
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
Slot: z.int().describe('0 — the outfit being worn'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
|
||||
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
|
||||
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
|
||||
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
|
||||
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
|
||||
* structured form, and the client sends both. `Selections` arrives empty — the actual
|
||||
* selections are inside those strings.
|
||||
*/
|
||||
export const OutfitsMeRequest = z.object({
|
||||
DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'),
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
Selections: JsonArray.describe('Empty in observed saves'),
|
||||
Slot: z.int(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
@@ -588,13 +668,16 @@ export const PlayerEventsPage = z.object({
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
||||
* which is a real category; `Message` is null, not an empty string — the client
|
||||
* distinguishes "no message" from a blank one.
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet), mirroring the reference server's stub
|
||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||
* they carry their C# defaults (false / null).
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
CURRENT_OUTFIT_SLOT,
|
||||
getOutfit,
|
||||
inventionDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
@@ -46,6 +49,9 @@ import {
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
@@ -234,6 +240,141 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
// can render the custom version instead. Nothing stores custom items yet, so nothing
|
||||
// has a save — an empty list means "use the legacy items as-is".
|
||||
.post(
|
||||
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Custom-item saves for legacy avatar items',
|
||||
description:
|
||||
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
|
||||
'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
|
||||
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
|
||||
'request body is ignored.\n\n' +
|
||||
'The value shape is the official one, recorded here for documentation; we never ' +
|
||||
'emit one until custom items are stored.',
|
||||
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
|
||||
}),
|
||||
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
)
|
||||
|
||||
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
|
||||
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
|
||||
// worker's saved-outfit slots live in); a player who has never saved gets the
|
||||
// brand-new-account envelope instead.
|
||||
.get(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s outfit',
|
||||
description:
|
||||
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
|
||||
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
|
||||
'handed back exactly as it was saved, since the payload’s heavy fields are the ' +
|
||||
'client’s own JSON-in-a-string documents.\n\n' +
|
||||
'A player who has never saved gets the brand-new-account envelope: all-null ' +
|
||||
'`LegacyData`, no `Selections`, `DataVersion` 9.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
|
||||
if (outfit !== null) return c.json(outfit)
|
||||
|
||||
return c.json({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Saving an outfit through the same bare path — into the slot the body names, which
|
||||
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
|
||||
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
|
||||
// it has to parse back. Answers the saved outfit, which is what the client re-renders
|
||||
// from.
|
||||
.put(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save the caller’s outfit',
|
||||
description:
|
||||
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
|
||||
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
|
||||
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
|
||||
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
|
||||
'strings by the client’s own serializer, so nothing here parses or re-encodes them.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
|
||||
responses: {
|
||||
200: json(OutfitsMeRequest, 'The outfit as stored'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
// The client sends `Slot`; a body without one saves the worn outfit.
|
||||
const outfit = {
|
||||
...body,
|
||||
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
|
||||
}
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return c.json(outfit)
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||
// rather than guessing.
|
||||
.get(
|
||||
'/outfits/me/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s saved outfits',
|
||||
description:
|
||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
||||
'list should carry, and in what shape, is not pinned down yet.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get(
|
||||
|
||||
@@ -61,11 +61,16 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
||||
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
|
||||
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
|
||||
// `Message` is null, not an empty string — the client distinguishes "no message"
|
||||
// from a blank one.
|
||||
.get(
|
||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
|
||||
// the reference server's stub `ReturnModerationBlockDetails()`.
|
||||
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
|
||||
// `Message` is null, not the empty string that stub sends — the client distinguishes
|
||||
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
|
||||
// DTO but left unset there, so they go out with their C# defaults.
|
||||
// The newer client POSTs this with no body despite it being a pure read; it answers
|
||||
// GET too, so the path is reachable from either build.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
@@ -73,11 +78,13 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
||||
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
|
||||
'not wired to them, so it is always the “not blocked” answer. Two details matter ' +
|
||||
'to the client: ' +
|
||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||
'message” from a blank one.',
|
||||
'not wired to them, so it is always the “not blocked” answer, following the ' +
|
||||
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
|
||||
'is a real category, and `Message` is null rather than the empty string that stub ' +
|
||||
'sends — the client distinguishes “no message” from a blank one. ' +
|
||||
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
|
||||
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
|
||||
'it with no body.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
(c) =>
|
||||
@@ -105,6 +112,43 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json([])
|
||||
) // TODO: hydrate from JSON/vtkreasons.json
|
||||
// The client asking whether IT should run its referee moderation — the in-client
|
||||
// review flow a player with referee standing gets shown. Deliberately `false` for
|
||||
// everyone: this is an archival server, and the referee program is one of the live
|
||||
// moderation systems it does not run. Answering true would put the client into a flow
|
||||
// with no cases behind it. A POST despite being a pure read, which is how the client
|
||||
// asks.
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/referee',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is a referee',
|
||||
description:
|
||||
'A bare JSON `false` — no envelope. The game client asks this to decide whether to ' +
|
||||
'run its referee moderation flow. Always false: the referee program is switched ' +
|
||||
'off here rather than unimplemented, since this server is archival.',
|
||||
responses: { 200: json(BareBoolean, 'Always `false` — the program is off') },
|
||||
}),
|
||||
(c) => c.json(false)
|
||||
)
|
||||
// The referee's own case files — the reviews assigned to them. Empty for the same
|
||||
// reason the flag above is false: the program is off, so no case is ever assigned. A
|
||||
// caller reaching this at all has gone past that flag, so the empty list is a second
|
||||
// line of defence rather than the normal path. A GET, unlike its POSTing neighbours
|
||||
// in this flow.
|
||||
.get(
|
||||
'/api/referee/files',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Referee case files',
|
||||
description:
|
||||
'The moderation cases assigned to the caller as a referee. Always empty — the ' +
|
||||
'referee program is switched off here (see `/api/PlayerReporting/v1/referee`), so ' +
|
||||
'nothing is ever assigned.',
|
||||
responses: { 200: json(JsonArray, 'An empty list — no cases are ever assigned') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/hile',
|
||||
describeRoute({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LEVEL_REQUIRED_XP,
|
||||
LEVEL_REWARDS,
|
||||
MAX_LEVEL,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RELATIONSHIP_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
@@ -108,6 +109,9 @@ beforeAll(async () => {
|
||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
@@ -269,23 +273,45 @@ describe('public endpoints', () => {
|
||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = no category (0 is a real one), and Message is null.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
||||
// GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
||||
async (method) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('POST /api/PlayerReporting/v1/referee says the caller is not one', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/referee`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// A bare boolean, not an envelope or a list.
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /api/referee/files has no cases', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/referee/files`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// Unauthenticated by design — the client posts this before it has an account, so
|
||||
@@ -462,6 +488,128 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }),
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
})
|
||||
|
||||
test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Account 77 never saves an outfit, so it keeps getting the new-account envelope.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
|
||||
// The client's own payload, trimmed to one selection: the point is that the heavy
|
||||
// JSON-in-a-string fields survive the round trip as strings, unparsed.
|
||||
const outfit = {
|
||||
DataVersion: 2,
|
||||
LegacyData: {
|
||||
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
|
||||
SelectionsV2:
|
||||
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
|
||||
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
|
||||
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
|
||||
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
|
||||
},
|
||||
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
|
||||
Selections: [],
|
||||
Slot: 0,
|
||||
Name: null,
|
||||
Accessibility: 1,
|
||||
ThumbnailFileName: null,
|
||||
}
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(outfit)
|
||||
|
||||
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
|
||||
// strings, not re-encoded objects.
|
||||
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await read.json()).toEqual(outfit)
|
||||
|
||||
// Re-saving overwrites slot 0 rather than adding a second row.
|
||||
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(changed),
|
||||
})
|
||||
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await reread.json()).toEqual(changed)
|
||||
const rows = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
|
||||
).first<{ n: number }>()
|
||||
expect(rows?.n).toBe(1)
|
||||
|
||||
// A save naming another slot does not touch what the caller is wearing.
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
|
||||
})
|
||||
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
||||
})
|
||||
|
||||
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -3650,6 +3798,7 @@ describe('openapi', () => {
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/referee/files',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
'GET /api/relationships/v1/favorite',
|
||||
'GET /api/relationships/v1/ignore',
|
||||
@@ -3667,11 +3816,16 @@ describe('openapi', () => {
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/islandedversions',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /outfits/me',
|
||||
'GET /outfits/me/saved',
|
||||
'GET /voice/config',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'POST /api/PlayerReporting/v1/referee',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
@@ -3705,6 +3859,7 @@ describe('openapi', () => {
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'PUT /outfits/me',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — an undescribed one renders as a bare path.
|
||||
|
||||
Reference in New Issue
Block a user