diff --git a/src/cosmetics-data-source.ts b/src/cosmetics-data-source.ts index 8dd2585..2ea4444 100644 --- a/src/cosmetics-data-source.ts +++ b/src/cosmetics-data-source.ts @@ -4,6 +4,7 @@ import { DataSource, DataSourceOptions } from "typeorm"; import { config } from "./evolution-types/src/config"; import { CosmeticEntity } from "./modules/catalog/infrastructure/CosmeticEntity"; import { EntitlementEntity } from "./modules/entitlements/infrastructure/EntitlementEntity"; +import { UserEmoteBarEntity } from "./modules/emote-bar/infrastructure/UserEmoteBarEntity"; import { UserLoadoutEntity } from "./modules/loadout/infrastructure/UserLoadoutEntity"; // Cosmetics owns its own DataSource and migration history, isolated from the shared @@ -21,7 +22,7 @@ const options: DataSourceOptions = { database: config.postgres.database, synchronize: false, logging: true, - entities: [CosmeticEntity, UserLoadoutEntity, EntitlementEntity], + entities: [CosmeticEntity, UserLoadoutEntity, EntitlementEntity, UserEmoteBarEntity], subscribers: [], migrations: [join(__dirname, "/migrations/*.ts")], migrationsTableName: "cosmetics_migrations", diff --git a/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts b/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts new file mode 100644 index 0000000..82f07ab --- /dev/null +++ b/src/migrations/1781700000000-add-emote-pack-cosmetic-type.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Adds the EMOTE_PACK cosmetic type: a folder of Lottie emotes described by a + * `pack.json`, owned like any other cosmetic but never equipped in the loadout. + * + * No row is inserted here. `ALTER TYPE ... ADD VALUE` runs inside a + * transaction on PG >= 12, but the freshly added value cannot be USED in that + * same transaction. + */ +export class AddEmotePackCosmeticType1781700000000 implements MigrationInterface { + name = "AddEmotePackCosmeticType1781700000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "cosmetic_type_enum" ADD VALUE IF NOT EXISTS 'EMOTE_PACK'`); + } + + async down(queryRunner: QueryRunner): Promise { + // Postgres cannot remove a single enum value without recreating the whole + // type and rewriting every column that uses it. Deleting the rows is the + // reversible part; the value itself is left in place, inert. + await queryRunner.query(`DELETE FROM "cosmetics" WHERE "type" = 'EMOTE_PACK'`); + } +} diff --git a/src/migrations/1781800000000-create-user-emote-bars.ts b/src/migrations/1781800000000-create-user-emote-bars.ts new file mode 100644 index 0000000..6284eb1 --- /dev/null +++ b/src/migrations/1781800000000-create-user-emote-bars.ts @@ -0,0 +1,26 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * One row per user holding the 8 emote refs of their bar, in slot order. The bar is always + * read and replaced as a whole, so a single array column makes every save one atomic upsert. + * The foreign key to users(id) is raw SQL so this DataSource never maps the shared users table. + */ +export class CreateUserEmoteBars1781800000000 implements MigrationInterface { + name = "CreateUserEmoteBars1781800000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_emote_bars" ("user_id" character varying NOT NULL, "emote_refs" character varying(48) array NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_user_emote_bars" PRIMARY KEY ("user_id"), CONSTRAINT "CHK_user_emote_bars_slots" CHECK (cardinality("emote_refs") = 8))`, + ); + await queryRunner.query( + `ALTER TABLE "user_emote_bars" ADD CONSTRAINT "FK_user_emote_bars_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_emote_bars" DROP CONSTRAINT "FK_user_emote_bars_user"`, + ); + await queryRunner.query(`DROP TABLE "user_emote_bars"`); + } +} diff --git a/src/modules/catalog/application/PublishCosmetic.ts b/src/modules/catalog/application/PublishCosmetic.ts index baaf44c..d5d21df 100644 --- a/src/modules/catalog/application/PublishCosmetic.ts +++ b/src/modules/catalog/application/PublishCosmetic.ts @@ -5,8 +5,12 @@ import { Cosmetic } from "../domain/Cosmetic"; import type { CosmeticRepository } from "../domain/CosmeticRepository"; import { CosmeticTier } from "../domain/CosmeticTier"; import { CosmeticType } from "../domain/CosmeticType"; +import { assertEmotePackFiles, MAX_EMOTE_PACK_FILES } from "../domain/EmotePack"; const MAX_FILES = 8; +const MAX_FILES_BY_TYPE: Partial> = { + [CosmeticType.EMOTE_PACK]: MAX_EMOTE_PACK_FILES, +}; const MAX_TOTAL_BYTES = 25 * 1024 * 1024; const SAFE_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -18,6 +22,7 @@ const PREFIX_BY_TYPE: Partial> = { [CosmeticType.PLAYMAT]: "playmats", [CosmeticType.AVATAR]: "avatars", [CosmeticType.LANE]: "lanes", + [CosmeticType.EMOTE_PACK]: "emote-packs", }; export interface PublishCosmeticFile { @@ -72,6 +77,10 @@ function assertAssetRef(type: CosmeticType, assetRef: string): void { } } +function slugOf(assetRef: string): string { + return assetRef.split("/")[1] ?? ""; +} + function assertRequiredFiles(type: CosmeticType, names: ReadonlySet): void { const lower = new Set(Array.from(names, (name) => name.toLowerCase())); if ((type === CosmeticType.SLEEVE || type === CosmeticType.AVATAR) && !lower.has("render.jpg")) { @@ -100,8 +109,9 @@ export class PublishCosmetic { async run(input: PublishCosmeticInput): Promise { assertAssetRef(input.type, input.assetRef); - if (input.files.length === 0 || input.files.length > MAX_FILES) { - throw new InvalidArgumentError(`A cosmetic requires between 1 and ${MAX_FILES} files`); + const maxFiles = MAX_FILES_BY_TYPE[input.type] ?? MAX_FILES; + if (input.files.length === 0 || input.files.length > maxFiles) { + throw new InvalidArgumentError(`A cosmetic requires between 1 and ${maxFiles} files`); } const names = new Set(); @@ -124,6 +134,9 @@ export class PublishCosmetic { throw new InvalidArgumentError("Cosmetic assets exceed the 25 MB upload limit"); } assertRequiredFiles(input.type, names); + if (input.type === CosmeticType.EMOTE_PACK) { + assertEmotePackFiles(slugOf(input.assetRef), input.files); + } const duplicate = (await this.cosmetics.findAll()).find( (cosmetic) => cosmetic.assetRef === input.assetRef, diff --git a/src/modules/catalog/domain/CosmeticType.ts b/src/modules/catalog/domain/CosmeticType.ts index b3fd9d1..74f2ad0 100644 --- a/src/modules/catalog/domain/CosmeticType.ts +++ b/src/modules/catalog/domain/CosmeticType.ts @@ -8,4 +8,11 @@ export enum CosmeticType { TITLE = "TITLE", /** The frame a player's zones wear, worn beside the playmat. */ LANE = "LANE", + /** A set of Lottie emotes a player can place on the emote bar. */ + EMOTE_PACK = "EMOTE_PACK", +} + +/** Types worn through the one-item-per-type loadout; an EMOTE_PACK is picked emote by emote. */ +export function isEquippableType(type: CosmeticType): boolean { + return type !== CosmeticType.EMOTE_PACK; } diff --git a/src/modules/catalog/domain/EmotePack.ts b/src/modules/catalog/domain/EmotePack.ts new file mode 100644 index 0000000..a6b6592 --- /dev/null +++ b/src/modules/catalog/domain/EmotePack.ts @@ -0,0 +1,126 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import { assertVectorLottie } from "./LottieAnimation"; + +export const EMOTE_PACK_MANIFEST = "pack.json"; +export const MAX_EMOTES_PER_PACK = 12; +/** The manifest plus one Lottie per emote. */ +export const MAX_EMOTE_PACK_FILES = MAX_EMOTES_PER_PACK + 1; + +const MANIFEST_VERSION = 1; +const MAX_KEY_LENGTH = 20; +const MAX_LABEL_LENGTH = 32; +/** `/` travels over the duel wire as the emote reference. */ +const MAX_REF_BYTES = 48; +const EMOTE_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export interface EmotePackFile { + readonly name: string; + readonly bytes: Uint8Array; +} + +interface ManifestEmote { + readonly key: string; + readonly label: string; + readonly file: string; +} + +function parseManifest(bytes: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} is not valid JSON`); + } +} + +function parseEmote(raw: unknown, slug: string): ManifestEmote { + const entry = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const { key, label, file } = entry; + + if (typeof key !== "string" || key.length > MAX_KEY_LENGTH || !EMOTE_KEY.test(key)) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote key ${JSON.stringify(key)} must be 1 to ${MAX_KEY_LENGTH} lowercase kebab-case characters`, + ); + } + const trimmedLabel = typeof label === "string" ? label.trim() : ""; + if (trimmedLabel.length < 1 || trimmedLabel.length > MAX_LABEL_LENGTH) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote "${key}" needs a label of 1 to ${MAX_LABEL_LENGTH} characters`, + ); + } + if (file !== `${key}.json`) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote "${key}" must use file "${key}.json"`, + ); + } + const ref = `${slug}/${key}`; + if (new TextEncoder().encode(ref).byteLength > MAX_REF_BYTES) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} emote ref "${ref}" exceeds ${MAX_REF_BYTES} bytes`, + ); + } + return { key, label: trimmedLabel, file }; +} + +function parseEmotes(manifest: unknown, slug: string): ManifestEmote[] { + const root = (typeof manifest === "object" && manifest !== null ? manifest : {}) as Record< + string, + unknown + >; + if (root.version !== MANIFEST_VERSION) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} version must be ${MANIFEST_VERSION}`); + } + if (!Array.isArray(root.emotes)) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} requires an emotes array`); + } + if (root.emotes.length < 1 || root.emotes.length > MAX_EMOTES_PER_PACK) { + throw new InvalidArgumentError( + `EMOTE_PACK requires between 1 and ${MAX_EMOTES_PER_PACK} emotes`, + ); + } + + const seen = new Set(); + return root.emotes.map((raw) => { + const emote = parseEmote(raw, slug); + if (seen.has(emote.key)) { + throw new InvalidArgumentError(`${EMOTE_PACK_MANIFEST} repeats emote key "${emote.key}"`); + } + seen.add(emote.key); + return emote; + }); +} + +/** + * Checks that an emote pack is exactly one `pack.json` plus the Lottie files it lists, + * and that every Lottie is valid vector-only animation. + */ +export function assertEmotePackFiles(slug: string, files: readonly EmotePackFile[]): void { + for (const file of files) { + if (file.name !== EMOTE_PACK_MANIFEST && !file.name.endsWith(".json")) { + throw new InvalidArgumentError( + `EMOTE_PACK file "${file.name}" must be ${EMOTE_PACK_MANIFEST} or a .json Lottie`, + ); + } + } + + const manifests = files.filter((file) => file.name === EMOTE_PACK_MANIFEST); + if (manifests.length !== 1) { + throw new InvalidArgumentError(`EMOTE_PACK requires exactly one ${EMOTE_PACK_MANIFEST}`); + } + const emotes = parseEmotes(parseManifest(manifests[0].bytes), slug); + + const lottieFiles = files.filter((file) => file.name !== EMOTE_PACK_MANIFEST); + const listed = new Set(emotes.map((emote) => emote.file)); + for (const emote of emotes) { + if (!lottieFiles.some((file) => file.name === emote.file)) { + throw new InvalidArgumentError( + `${EMOTE_PACK_MANIFEST} lists "${emote.file}" but it was not uploaded`, + ); + } + } + for (const file of lottieFiles) { + if (!listed.has(file.name)) { + throw new InvalidArgumentError(`"${file.name}" is not listed in ${EMOTE_PACK_MANIFEST}`); + } + assertVectorLottie(file.name, file.bytes); + } +} diff --git a/src/modules/catalog/domain/LottieAnimation.ts b/src/modules/catalog/domain/LottieAnimation.ts new file mode 100644 index 0000000..bb4b124 --- /dev/null +++ b/src/modules/catalog/domain/LottieAnimation.ts @@ -0,0 +1,111 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; + +export const MAX_LOTTIE_BYTES = 200 * 1024; +export const MAX_LOTTIE_SECONDS = 3; +export const MAX_LOTTIE_DEPTH = 64; + +const TEXT_LAYER_TYPE = 5; +const VERSION = /^\d+(?:\.\d+)*$/; + +type Json = Record; + +function isObject(value: unknown): value is Json { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +// Lottie keeps expressions in an `x` string next to the animated value it drives, at any +// depth, so the whole tree has to be walked. The walk is iterative and bounded so hostile +// nesting inside the size cap is rejected instead of exhausting the stack. +function scanTree(fileName: string, root: unknown): { hasExpression: boolean } { + const pending: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 0 }]; + for (let entry = pending.pop(); entry; entry = pending.pop()) { + const { node, depth } = entry; + if (typeof node !== "object" || node === null) continue; + if (depth > MAX_LOTTIE_DEPTH) { + throw new InvalidArgumentError(`${fileName} is nested too deeply`); + } + if (Array.isArray(node)) { + for (const child of node) pending.push({ node: child, depth: depth + 1 }); + continue; + } + for (const [key, value] of Object.entries(node)) { + if (key === "x" && typeof value === "string") return { hasExpression: true }; + pending.push({ node: value, depth: depth + 1 }); + } + } + return { hasExpression: false }; +} + +function layerGroups(rootLayers: unknown[], assets: unknown[]): unknown[][] { + const groups: unknown[][] = [rootLayers]; + for (const asset of assets) { + if (isObject(asset) && Array.isArray(asset.layers)) groups.push(asset.layers); + } + return groups; +} + +/** + * Accepts only the vector subset the client renders safely with lottie-web's SVG + * renderer: no raster or external assets, no text or fonts, no expressions, and a + * bounded size and duration. + */ +export function assertVectorLottie(fileName: string, bytes: Uint8Array): void { + if (bytes.byteLength > MAX_LOTTIE_BYTES) { + throw new InvalidArgumentError(`${fileName} exceeds ${MAX_LOTTIE_BYTES / 1024} KB`); + } + + let animation: unknown; + try { + animation = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new InvalidArgumentError(`${fileName} is not valid JSON`); + } + if (!isObject(animation)) { + throw new InvalidArgumentError(`${fileName} must be a JSON object`); + } + + if (typeof animation.v !== "string" || !VERSION.test(animation.v)) { + throw new InvalidArgumentError(`${fileName} must declare a version string in v`); + } + if (!isFiniteNumber(animation.fr) || animation.fr <= 0) { + throw new InvalidArgumentError(`${fileName} requires a frame rate fr greater than 0`); + } + const { ip, op, w, h } = animation; + if (!isFiniteNumber(ip) || !isFiniteNumber(op) || !isFiniteNumber(w) || !isFiniteNumber(h)) { + throw new InvalidArgumentError(`${fileName} requires numeric ip, op, w and h`); + } + if (op <= ip) { + throw new InvalidArgumentError(`${fileName} requires op greater than ip`); + } + if (!Array.isArray(animation.layers)) { + throw new InvalidArgumentError(`${fileName} requires a layers array`); + } + if ((op - ip) / animation.fr > MAX_LOTTIE_SECONDS) { + throw new InvalidArgumentError(`${fileName} lasts longer than ${MAX_LOTTIE_SECONDS} seconds`); + } + + const assets = animation.assets ?? []; + if (!Array.isArray(assets)) { + throw new InvalidArgumentError(`${fileName} requires assets to be an array`); + } + if (assets.some((asset) => isObject(asset) && ("p" in asset || "u" in asset))) { + throw new InvalidArgumentError(`${fileName} embeds an image or external asset`); + } + if ("fonts" in animation || "chars" in animation) { + throw new InvalidArgumentError(`${fileName} must not declare fonts or chars`); + } + if ( + layerGroups(animation.layers, assets).some((layers) => + layers.some((layer) => isObject(layer) && layer.ty === TEXT_LAYER_TYPE), + ) + ) { + throw new InvalidArgumentError(`${fileName} contains a text layer`); + } + if (scanTree(fileName, animation).hasExpression) { + throw new InvalidArgumentError(`${fileName} contains an expression`); + } +} diff --git a/src/modules/emote-bar/application/GetMyEmoteBar.ts b/src/modules/emote-bar/application/GetMyEmoteBar.ts new file mode 100644 index 0000000..860dc6f --- /dev/null +++ b/src/modules/emote-bar/application/GetMyEmoteBar.ts @@ -0,0 +1,89 @@ +import { AssetUrlSigner, SignedAssetManifest } from "../../assets/domain/AssetUrlSigner"; +import { EMOTE_PACK_MANIFEST } from "../../catalog/domain/EmotePack"; +import { Cosmetic } from "../../catalog/domain/Cosmetic"; +import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository"; +import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; +import { EmoteBar } from "../domain/EmoteBar"; +import { EmoteBarRepository } from "../domain/EmoteBarRepository"; +import { DEFAULT_EMOTE_IDS } from "../domain/EmoteRef"; +import { activeEmotePacksBySlug, emoteFileName, packHasEmote } from "./activeEmotePacks"; + +export interface EmoteBarSlot { + ref: string; + /** Cosmetic id of the pack the emote comes from; null for a default emote. */ + packId: string | null; + /** Signed URL of the emote's Lottie; null for a default emote, which the client bundles. */ + assetUrl: string | null; + /** Signed URL of the pack's `pack.json`; null for a default emote. */ + packUrl: string | null; + assetsExpiresAt: string | null; +} + +export interface EmoteBarView { + slots: EmoteBarSlot[]; +} + +function defaultSlot(index: number): EmoteBarSlot { + return { + ref: DEFAULT_EMOTE_IDS[index], + packId: null, + assetUrl: null, + packUrl: null, + assetsExpiresAt: null, + }; +} + +/** + * Resolves any user's bar against the catalog and their current entitlements. A saved slot + * that is no longer usable reads as the default emote of that slot; storage is not rewritten. + */ +export class GetMyEmoteBar { + constructor( + private readonly cosmetics: CosmeticRepository, + private readonly bars: EmoteBarRepository, + private readonly gatekeeper: EntitlementsGatekeeper, + private readonly signer: AssetUrlSigner, + ) {} + + async run(userId: string): Promise { + const bar = (await this.bars.findByUserId(userId)) ?? EmoteBar.defaults(); + const refs = bar.refs(); + if (refs.every((ref) => ref.isDefault)) { + return { slots: refs.map((ref, index) => ({ ...defaultSlot(index), ref: ref.toString() })) }; + } + + const packs = activeEmotePacksBySlug(await this.cosmetics.findAll()); + const access = await this.gatekeeper.accessFor(userId); + const manifests = new Map>(); + const manifestOf = (pack: Cosmetic) => { + let manifest = manifests.get(pack.id); + if (!manifest) { + manifest = this.signer.signManifest(pack.assetRef, pack.assetFiles ?? undefined); + manifests.set(pack.id, manifest); + } + return manifest; + }; + + const slots = await Promise.all( + refs.map(async (ref, index): Promise => { + if (ref.isDefault) return { ...defaultSlot(index), ref: ref.toString() }; + + const pack = packs.get(ref.packSlug as string); + const key = ref.emoteKey as string; + if (!pack || !access.canUse(pack) || !packHasEmote(pack, key)) return defaultSlot(index); + + const manifest = await manifestOf(pack); + const assetUrl = manifest.assets[emoteFileName(key)]; + if (!assetUrl) return defaultSlot(index); + return { + ref: ref.toString(), + packId: pack.id, + assetUrl, + packUrl: manifest.assets[EMOTE_PACK_MANIFEST] ?? null, + assetsExpiresAt: manifest.expiresAt, + }; + }), + ); + return { slots }; + } +} diff --git a/src/modules/emote-bar/application/SaveMyEmoteBar.ts b/src/modules/emote-bar/application/SaveMyEmoteBar.ts new file mode 100644 index 0000000..2c15222 --- /dev/null +++ b/src/modules/emote-bar/application/SaveMyEmoteBar.ts @@ -0,0 +1,37 @@ +import { ForbiddenError } from "../../../shared/errors/ForbiddenError"; +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository"; +import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; +import { EmoteBar } from "../domain/EmoteBar"; +import { EmoteBarRepository } from "../domain/EmoteBarRepository"; +import { activeEmotePacksBySlug, packHasEmote } from "./activeEmotePacks"; + +export class SaveMyEmoteBar { + constructor( + private readonly cosmetics: CosmeticRepository, + private readonly bars: EmoteBarRepository, + private readonly gatekeeper: EntitlementsGatekeeper, + ) {} + + async run({ userId, slots }: { userId: string; slots: readonly string[] }): Promise { + const bar = EmoteBar.create(slots); + const packRefs = bar.refs().filter((ref) => !ref.isDefault); + + if (packRefs.length > 0) { + const packs = activeEmotePacksBySlug(await this.cosmetics.findAll()); + // Access is always decided by the gate, as when equipping a cosmetic. + const access = await this.gatekeeper.accessFor(userId); + for (const ref of packRefs) { + const pack = packs.get(ref.packSlug as string); + if (!pack || !packHasEmote(pack, ref.emoteKey as string)) { + throw new InvalidArgumentError(`Emote "${ref}" does not exist`); + } + if (!access.canUse(pack)) { + throw new ForbiddenError(`User is not entitled to cosmetic ${pack.id}`); + } + } + } + + await this.bars.save(userId, bar); + } +} diff --git a/src/modules/emote-bar/application/activeEmotePacks.ts b/src/modules/emote-bar/application/activeEmotePacks.ts new file mode 100644 index 0000000..02269af --- /dev/null +++ b/src/modules/emote-bar/application/activeEmotePacks.ts @@ -0,0 +1,25 @@ +import { Cosmetic } from "../../catalog/domain/Cosmetic"; +import { CosmeticType } from "../../catalog/domain/CosmeticType"; +import { EMOTE_PACK_MANIFEST } from "../../catalog/domain/EmotePack"; + +const PACK_PREFIX = "emote-packs/"; + +/** Active EMOTE_PACK cosmetics keyed by the slug in their `emote-packs//` folder. */ +export function activeEmotePacksBySlug(cosmetics: readonly Cosmetic[]): Map { + const bySlug = new Map(); + for (const cosmetic of cosmetics) { + if (cosmetic.type !== CosmeticType.EMOTE_PACK || !cosmetic.active) continue; + if (!cosmetic.assetRef.startsWith(PACK_PREFIX)) continue; + bySlug.set(cosmetic.assetRef.slice(PACK_PREFIX.length, -1), cosmetic); + } + return bySlug; +} + +export function emoteFileName(key: string): string { + return `${key}.json`; +} + +export function packHasEmote(pack: Cosmetic, key: string): boolean { + const file = emoteFileName(key); + return file !== EMOTE_PACK_MANIFEST && !!pack.assetFiles?.includes(file); +} diff --git a/src/modules/emote-bar/domain/EmoteBar.ts b/src/modules/emote-bar/domain/EmoteBar.ts new file mode 100644 index 0000000..9f26ab8 --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteBar.ts @@ -0,0 +1,32 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; +import { DEFAULT_EMOTE_IDS, EmoteRef } from "./EmoteRef"; + +export const EMOTE_BAR_SLOTS = 8; + +// A player's emote bar: exactly 8 distinct emotes, one per slot, in display order. +export class EmoteBar { + private constructor(private readonly slots: readonly EmoteRef[]) {} + + static defaults(): EmoteBar { + return EmoteBar.create(DEFAULT_EMOTE_IDS); + } + + static create(refs: readonly string[]): EmoteBar { + if (refs.length !== EMOTE_BAR_SLOTS) { + throw new InvalidArgumentError(`Emote bar requires exactly ${EMOTE_BAR_SLOTS} slots`); + } + const parsed = refs.map((raw) => EmoteRef.parse(raw)); + const seen = new Set(); + for (const ref of parsed) { + if (seen.has(ref.toString())) { + throw new InvalidArgumentError(`Emote bar repeats "${ref}"`); + } + seen.add(ref.toString()); + } + return new EmoteBar(parsed); + } + + refs(): readonly EmoteRef[] { + return this.slots; + } +} diff --git a/src/modules/emote-bar/domain/EmoteBarRepository.ts b/src/modules/emote-bar/domain/EmoteBarRepository.ts new file mode 100644 index 0000000..baae6df --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteBarRepository.ts @@ -0,0 +1,9 @@ +import { EmoteBar } from "./EmoteBar"; + +export interface EmoteBarRepository { + /** The saved bar, or null when the user never saved one. */ + findByUserId(userId: string): Promise; + + /** Replaces the whole bar atomically. */ + save(userId: string, bar: EmoteBar): Promise; +} diff --git a/src/modules/emote-bar/domain/EmoteRef.ts b/src/modules/emote-bar/domain/EmoteRef.ts new file mode 100644 index 0000000..68ef82a --- /dev/null +++ b/src/modules/emote-bar/domain/EmoteRef.ts @@ -0,0 +1,54 @@ +import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; + +/** Emotes bundled with the client; they need no pack and no asset URL. */ +export const DEFAULT_EMOTE_IDS: readonly string[] = [ + "wave", + "thumbsup", + "fire", + "thinking", + "cool", + "mindblown", + "sweat", + "cry", +]; + +const MAX_REF_BYTES = 48; +const MAX_KEY_LENGTH = 20; +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Names one emote: either a default id (`wave`) or `/`. The string + * form is what the duel wire carries, so it is capped at 48 bytes. + */ +export class EmoteRef { + private constructor( + private readonly raw: string, + public readonly packSlug: string | null, + public readonly emoteKey: string | null, + ) {} + + static parse(raw: string): EmoteRef { + if (DEFAULT_EMOTE_IDS.includes(raw)) return new EmoteRef(raw, null, null); + + const parts = raw.split("/"); + const [slug, key] = parts; + const wellFormed = + parts.length === 2 && + KEBAB.test(slug) && + KEBAB.test(key) && + key.length <= MAX_KEY_LENGTH && + new TextEncoder().encode(raw).byteLength <= MAX_REF_BYTES; + if (!wellFormed) { + throw new InvalidArgumentError(`Invalid emote reference "${raw}"`); + } + return new EmoteRef(raw, slug, key); + } + + get isDefault(): boolean { + return this.packSlug === null; + } + + toString(): string { + return this.raw; + } +} diff --git a/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts b/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts new file mode 100644 index 0000000..4065e3b --- /dev/null +++ b/src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository.ts @@ -0,0 +1,18 @@ +import { cosmeticsDataSource } from "../../../cosmetics-data-source"; +import { EmoteBar } from "../domain/EmoteBar"; +import { EmoteBarRepository } from "../domain/EmoteBarRepository"; +import { UserEmoteBarEntity } from "./UserEmoteBarEntity"; + +export class EmoteBarPostgresRepository implements EmoteBarRepository { + async findByUserId(userId: string): Promise { + const row = await cosmeticsDataSource.getRepository(UserEmoteBarEntity).findOneBy({ userId }); + return row ? EmoteBar.create(row.emoteRefs) : null; + } + + async save(userId: string, bar: EmoteBar): Promise { + // Upsert by primary key: the whole bar is replaced in a single statement. + await cosmeticsDataSource + .getRepository(UserEmoteBarEntity) + .save({ userId, emoteRefs: bar.refs().map(String) }); + } +} diff --git a/src/modules/emote-bar/infrastructure/EmoteBarSchemas.ts b/src/modules/emote-bar/infrastructure/EmoteBarSchemas.ts new file mode 100644 index 0000000..160d25b --- /dev/null +++ b/src/modules/emote-bar/infrastructure/EmoteBarSchemas.ts @@ -0,0 +1,28 @@ +import { t } from "elysia"; + +import { EMOTE_BAR_SLOTS } from "../domain/EmoteBar"; + +/** + * Entry of the emote bar. Default emotes carry no URLs: the client bundles them. A pack + * emote carries a signed URL for its Lottie and for the pack's `pack.json`. + */ +export const EmoteBarSlotSchema = t.Object({ + ref: t.String({ description: "Default emote id, or /" }), + packId: t.Union([t.String(), t.Null()], { description: "Cosmetic id of the pack" }), + assetUrl: t.Union([t.String(), t.Null()], { description: "Signed URL of the emote Lottie" }), + packUrl: t.Union([t.String(), t.Null()], { description: "Signed URL of the pack manifest" }), + assetsExpiresAt: t.Union([t.String(), t.Null()], { + description: "ISO 8601 expiry of the signed URLs", + }), +}); + +/** Response of GET and PUT /me/emote-bar: the 8 slots in order. */ +export const EmoteBarSchema = t.Object({ slots: t.Array(EmoteBarSlotSchema) }); + +export const SaveEmoteBarBodySchema = t.Object({ + slots: t.Array(t.String({ maxLength: 48 }), { + minItems: EMOTE_BAR_SLOTS, + maxItems: EMOTE_BAR_SLOTS, + description: "The 8 emote references, in slot order, without repeats", + }), +}); diff --git a/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts b/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts new file mode 100644 index 0000000..3d196b0 --- /dev/null +++ b/src/modules/emote-bar/infrastructure/UserEmoteBarEntity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm"; + +// One row per user; emote_refs holds the 8 slots in order. A CHECK constraint keeps it at 8. +@Entity({ name: "user_emote_bars" }) +export class UserEmoteBarEntity { + // FK to users(id), which is varchar in the shared schema — not uuid. + @PrimaryColumn({ name: "user_id", type: "varchar" }) + userId: string; + + @Column({ name: "emote_refs", type: "varchar", length: 48, array: true }) + emoteRefs: string[]; + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date; +} diff --git a/src/modules/loadout/application/EquipCosmetic.ts b/src/modules/loadout/application/EquipCosmetic.ts index f1ad9e8..626312c 100644 --- a/src/modules/loadout/application/EquipCosmetic.ts +++ b/src/modules/loadout/application/EquipCosmetic.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "../../../shared/errors/ForbiddenError"; import { InvalidArgumentError } from "../../../shared/errors/InvalidArgumentError"; import { NotFoundError } from "../../../shared/errors/NotFoundError"; import { CosmeticRepository } from "../../catalog/domain/CosmeticRepository"; -import { CosmeticType } from "../../catalog/domain/CosmeticType"; +import { CosmeticType, isEquippableType } from "../../catalog/domain/CosmeticType"; import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; import { LoadoutRepository } from "../domain/LoadoutRepository"; @@ -26,6 +26,11 @@ export class EquipCosmetic { if (!cosmetic || !cosmetic.active) { throw new NotFoundError(`Cosmetic ${cosmeticId} not found`); } + if (!isEquippableType(cosmetic.type)) { + throw new InvalidArgumentError( + `${cosmetic.type} cosmetics cannot be equipped in the loadout`, + ); + } if (cosmetic.type !== cosmeticType) { throw new InvalidArgumentError( `Cosmetic ${cosmeticId} cannot be equipped in the ${cosmeticType} slot`, diff --git a/src/server/routes/admin-cosmetics-router.ts b/src/server/routes/admin-cosmetics-router.ts index 71aa043..8764eea 100644 --- a/src/server/routes/admin-cosmetics-router.ts +++ b/src/server/routes/admin-cosmetics-router.ts @@ -100,7 +100,7 @@ export function createAdminCosmeticsRouter(deps: AdminCosmeticsRouterDependencie tier: t.Enum(CosmeticTier), assetRef: t.String({ minLength: 1, maxLength: 180 }), displayName: t.String({ minLength: 1, maxLength: 80 }), - files: t.Files({ minItems: 1, maxItems: 8, maxSize: "12m" }), + files: t.Files({ minItems: 1, maxItems: 13, maxSize: "12m" }), }), detail: { tags: ["Cosmetics Admin"], diff --git a/src/server/routes/emote-bar-router.ts b/src/server/routes/emote-bar-router.ts new file mode 100644 index 0000000..8c3283e --- /dev/null +++ b/src/server/routes/emote-bar-router.ts @@ -0,0 +1,91 @@ +import { bearer } from "@elysiajs/bearer"; +import { Elysia } from "elysia"; + +import { config } from "../../config"; +import { createR2AssetUrlSigner } from "../../modules/assets/infrastructure/createR2AssetUrlSigner"; +import { CosmeticPostgresRepository } from "../../modules/catalog/infrastructure/CosmeticPostgresRepository"; +import { GetMyEmoteBar } from "../../modules/emote-bar/application/GetMyEmoteBar"; +import { SaveMyEmoteBar } from "../../modules/emote-bar/application/SaveMyEmoteBar"; +import { EmoteBarPostgresRepository } from "../../modules/emote-bar/infrastructure/EmoteBarPostgresRepository"; +import { + EmoteBarSchema, + SaveEmoteBarBodySchema, +} from "../../modules/emote-bar/infrastructure/EmoteBarSchemas"; +import { EntitlementsGatekeeper } from "../../modules/entitlements/application/EntitlementsGatekeeper"; +import { EntitlementPostgresRepository } from "../../modules/entitlements/infrastructure/EntitlementPostgresRepository"; +import { JWT } from "../../shared/JWT"; +import { banGuard } from "../guards/bandGuard"; +import { errorResponses, jsonOk } from "../openapi/responses"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; + +const jwt = new JWT(config.jwt); +const cosmetics = new CosmeticPostgresRepository(); +const bars = new EmoteBarPostgresRepository(); +const gatekeeper = new EntitlementsGatekeeper(new EntitlementPostgresRepository()); + +const getMyEmoteBar = new GetMyEmoteBar(cosmetics, bars, gatekeeper, createR2AssetUrlSigner()); +const saveMyEmoteBar = new SaveMyEmoteBar(cosmetics, bars, gatekeeper); + +const exampleBar = { + slots: [ + { + ref: "halloween-duelist/ghost-wave", + packId: "cosmetic-1", + assetUrl: + "https://assets.evolutionygo.com/emote-packs/halloween-duelist/ghost-wave.json?sig=...", + packUrl: "https://assets.evolutionygo.com/emote-packs/halloween-duelist/pack.json?sig=...", + assetsExpiresAt: "2026-09-25T18:30:00.000Z", + }, + { ref: "thumbsup", packId: null, assetUrl: null, packUrl: null, assetsExpiresAt: null }, + ], +}; + +export const emoteBarRouter = new Elysia({ prefix: "/me/emote-bar" }) + .use(bearer()) + .guard(banGuard, (app) => + app + .get( + "/", + ({ bearer, set }) => { + preventSignedAssetResponseCaching(set); + const { id } = jwt.decode(bearer as string) as { id: string }; + return getMyEmoteBar.run(id); + }, + { + detail: { + tags: ["Cosmetics"], + summary: "Get my emote bar", + description: + "Returns the authenticated user's 8 emote slots. Without a saved bar these are the default emotes. A saved pack emote the user can no longer use reads as the default emote of its slot.", + security: [{ bearerAuth: [] }], + responses: { + 200: jsonOk(EmoteBarSchema, "Emote bar retrieved successfully", exampleBar), + ...errorResponses(401, 403), + }, + }, + }, + ) + .put( + "/", + async ({ bearer, body, set }) => { + preventSignedAssetResponseCaching(set); + const { id } = jwt.decode(bearer as string) as { id: string }; + await saveMyEmoteBar.run({ userId: id, slots: body.slots }); + return getMyEmoteBar.run(id); + }, + { + body: SaveEmoteBarBodySchema, + detail: { + tags: ["Cosmetics"], + summary: "Save my emote bar", + description: + "Replaces the whole bar after validating every emote: default ids are always allowed; a pack emote needs an active EMOTE_PACK the user can use that lists the emote.", + security: [{ bearerAuth: [] }], + responses: { + 200: jsonOk(EmoteBarSchema, "Emote bar saved", exampleBar), + ...errorResponses(400, 401, 403, 422), + }, + }, + }, + ), + ); diff --git a/src/server/server.ts b/src/server/server.ts index a00e088..22449bf 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -12,6 +12,7 @@ import { achievementsRouter, userAchievementsRouter } from "./routes/achievement import { banListRouter } from "./routes/ban-list-router"; import { cosmeticsRouter } from "./routes/cosmetics-router"; import { leaderboardRouter } from "./routes/leaderboard-router"; +import { emoteBarRouter } from "./routes/emote-bar-router"; import { loadoutRouter } from "./routes/loadout-router"; import { meCosmeticsRouter } from "./routes/me-cosmetics-router"; import { publicLoadoutRouter } from "./routes/public-loadout-router"; @@ -79,6 +80,7 @@ export function mountApiV1Routes(app: Elysia) { .use(ticketRouter) .use(cosmeticsRouter) .use(meCosmeticsRouter) + .use(emoteBarRouter) .use(loadoutRouter) .use(publicLoadoutRouter) .use(adminCosmeticsRouter) diff --git a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts index 32fd29f..c3b6267 100644 --- a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts +++ b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts @@ -255,4 +255,50 @@ describe("GetCosmeticsCatalog", () => { expect(accessForCalls).toBe(1); }); + + it("lists an entitled EMOTE_PACK with its pack.json and emote manifest", async () => { + const emotePack = Cosmetic.from({ + id: "pack-1", + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.DONOR, + assetRef: "emote-packs/halloween/", + displayName: "Halloween", + active: true, + assetFiles: ["pack.json", "ghost.json"], + }); + const manifestSigner: AssetUrlSigner = { + ...signer, + signManifest: async (prefix, files) => ({ + assets: Object.fromEntries((files ?? []).map((file) => [file, `signed:${prefix}${file}`])), + expiresAt: "2030-01-01T00:00:00.000Z", + }), + }; + const { gk } = gatekeeperWith([ + Entitlement.create({ + id: "e1", + userId: "user-1", + grantType: GrantType.COSMETIC, + grantValue: "pack-1", + source: EntitlementSource.CAMPAIGN, + expiresAt: null, + }), + ]); + const catalog = new GetCosmeticsCatalog(fakeRepo([sleeve, emotePack]), manifestSigner, gk); + + const result = await catalog.run({ type: CosmeticType.EMOTE_PACK }, "user-1"); + + expect(result).toEqual([ + { + id: "pack-1", + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.DONOR, + displayName: "Halloween", + assets: { + "pack.json": "signed:emote-packs/halloween/pack.json", + "ghost.json": "signed:emote-packs/halloween/ghost.json", + }, + assetsExpiresAt: "2030-01-01T00:00:00.000Z", + }, + ]); + }); }); diff --git a/tests/unit/modules/catalog/application/PublishCosmetic.test.ts b/tests/unit/modules/catalog/application/PublishCosmetic.test.ts index 1e48c2c..22642cd 100644 --- a/tests/unit/modules/catalog/application/PublishCosmetic.test.ts +++ b/tests/unit/modules/catalog/application/PublishCosmetic.test.ts @@ -176,4 +176,100 @@ describe("PublishCosmetic", () => { }), ).rejects.toThrow("assetRef for LANE must match lanes/kebab-case-slug/"); }); + describe("EMOTE_PACK", () => { + const encoder = new TextEncoder(); + const lottie = encoder.encode( + JSON.stringify({ v: "5.7.4", fr: 30, ip: 0, op: 30, w: 100, h: 100, layers: [] }), + ); + const pack = (keys: string[]) => [ + { + name: "pack.json", + bytes: encoder.encode( + JSON.stringify({ + version: 1, + emotes: keys.map((key) => ({ key, label: key, file: `${key}.json` })), + }), + ), + }, + ...keys.map((key) => ({ name: `${key}.json`, bytes: lottie })), + ]; + const emotePack = (files: ReturnType, assetRef = "emote-packs/halloween/") => ({ + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.EXCLUSIVE, + assetRef, + displayName: "Halloween Duelist", + files, + }); + + it("publishes pack.json and its Lottie files under emote-packs//", async () => { + const { publish, uploads, saved } = setup(); + + const result = await publish.run(emotePack(pack(["attack", "ghost-wave"]))); + + expect(uploads.map((upload) => [upload.key, upload.contentType])).toEqual([ + ["emote-packs/halloween/pack.json", "application/json"], + ["emote-packs/halloween/attack.json", "application/json"], + ["emote-packs/halloween/ghost-wave.json", "application/json"], + ]); + expect(result.assetFiles).toEqual(["pack.json", "attack.json", "ghost-wave.json"]); + expect(saved).toHaveLength(1); + }); + + it("accepts 13 files: pack.json plus 12 emotes", async () => { + const { publish, uploads } = setup(); + const keys = Array.from({ length: 12 }, (_, index) => `e${index}`); + + await publish.run(emotePack(pack(keys))); + + expect(uploads).toHaveLength(13); + }); + + it("keeps the 8 file limit for other types", async () => { + const { publish } = setup(); + const files = Array.from({ length: 9 }, (_, index) => ({ + name: `f${index}.png`, + bytes: new Uint8Array([1]), + })); + + await expect( + publish.run({ + type: CosmeticType.LANE, + tier: CosmeticTier.STANDARD, + assetRef: "lanes/stone/", + displayName: "Stone", + files, + }), + ).rejects.toThrow("A cosmetic requires between 1 and 8 files"); + }); + + it("rejects a pack with an invalid manifest or Lottie, before touching storage", async () => { + const { publish, uploads, saved } = setup(); + const files = pack(["attack"]); + files[1] = { name: "attack.json", bytes: encoder.encode('{"v":"5.7.4"}') }; + + await expect(publish.run(emotePack(files))).rejects.toThrow( + "attack.json requires a frame rate fr greater than 0", + ); + + expect(uploads).toHaveLength(0); + expect(saved).toHaveLength(0); + }); + + it("rejects an assetRef outside emote-packs//", async () => { + const { publish } = setup(); + + await expect(publish.run(emotePack(pack(["attack"]), "lanes/halloween/"))).rejects.toThrow( + "assetRef for EMOTE_PACK must match emote-packs/kebab-case-slug/", + ); + }); + + it("rejects an emote ref over 48 bytes using the slug from the assetRef", async () => { + const { publish } = setup(); + const slug = "s".repeat(30); + + await expect( + publish.run(emotePack(pack(["k".repeat(19)]), `emote-packs/${slug}/`)), + ).rejects.toThrow("exceeds 48 bytes"); + }); + }); }); diff --git a/tests/unit/modules/catalog/domain/EmotePack.test.ts b/tests/unit/modules/catalog/domain/EmotePack.test.ts new file mode 100644 index 0000000..3b4d3c4 --- /dev/null +++ b/tests/unit/modules/catalog/domain/EmotePack.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "bun:test"; + +import { + assertEmotePackFiles, + MAX_EMOTE_PACK_FILES, +} from "../../../../../src/modules/catalog/domain/EmotePack"; + +const encoder = new TextEncoder(); + +function lottie(): Uint8Array { + return encoder.encode( + JSON.stringify({ v: "5.7.4", fr: 30, ip: 0, op: 30, w: 100, h: 100, layers: [] }), + ); +} + +function manifest(emotes: unknown, version: unknown = 1): { name: string; bytes: Uint8Array } { + return { name: "pack.json", bytes: encoder.encode(JSON.stringify({ version, emotes })) }; +} + +function emote(key: string, label = key, file = `${key}.json`) { + return { key, label, file }; +} + +function files(keys: string[]) { + return keys.map((key) => ({ name: `${key}.json`, bytes: lottie() })); +} + +function reject(slug: string, input: Parameters[1], message: string) { + expect(() => assertEmotePackFiles(slug, input)).toThrow(message); +} + +describe("assertEmotePackFiles", () => { + it("accepts a manifest whose emotes match the uploaded Lottie files", () => { + expect(() => + assertEmotePackFiles("halloween", [ + manifest([emote("attack", "Attack"), emote("ghost-wave", "Ghost wave")]), + ...files(["attack", "ghost-wave"]), + ]), + ).not.toThrow(); + }); + + it("allows 13 files: pack.json plus 12 emotes", () => { + const keys = Array.from({ length: 12 }, (_, index) => `e${index}`); + expect(MAX_EMOTE_PACK_FILES).toBe(13); + expect(() => + assertEmotePackFiles("big", [manifest(keys.map((key) => emote(key))), ...files(keys)]), + ).not.toThrow(); + }); + + it("rejects a pack without pack.json", () => { + reject("halloween", files(["attack"]), "EMOTE_PACK requires exactly one pack.json"); + }); + + it("rejects a pack with no Lottie files", () => { + reject("halloween", [manifest([])], "EMOTE_PACK requires between 1 and 12 emotes"); + }); + + it("rejects more than 12 Lottie files", () => { + const keys = Array.from({ length: 13 }, (_, index) => `e${index}`); + reject( + "big", + [manifest(keys.map((key) => emote(key))), ...files(keys)], + "EMOTE_PACK requires between 1 and 12 emotes", + ); + }); + + it("rejects an uploaded file that is not .json", () => { + reject( + "halloween", + [manifest([emote("attack")]), ...files(["attack"]), { name: "attack.png", bytes: lottie() }], + 'EMOTE_PACK file "attack.png" must be pack.json or a .json Lottie', + ); + }); + + it("rejects a pack.json that is not valid JSON", () => { + reject( + "halloween", + [{ name: "pack.json", bytes: encoder.encode("{") }, ...files(["attack"])], + "pack.json is not valid JSON", + ); + }); + + it("rejects an unsupported manifest version", () => { + reject( + "halloween", + [manifest([emote("attack")], 2), ...files(["attack"])], + "pack.json version must be 1", + ); + }); + + it("rejects a manifest without an emotes array", () => { + reject( + "halloween", + [manifest("nope"), ...files(["attack"])], + "pack.json requires an emotes array", + ); + }); + + it("rejects emote keys that are not kebab-case or are too long", () => { + for (const key of ["Attack", "a_b", "-a", "a--b", "", "k".repeat(21)]) { + reject( + "halloween", + [manifest([emote(key, "x", "x.json")]), ...files(["x"])], + "pack.json emote key", + ); + } + }); + + it("rejects duplicate keys", () => { + reject( + "halloween", + [manifest([emote("attack"), emote("attack")]), ...files(["attack"])], + 'pack.json repeats emote key "attack"', + ); + }); + + it("rejects labels that are empty after trimming or longer than 32 characters", () => { + reject( + "halloween", + [manifest([emote("attack", " ")]), ...files(["attack"])], + 'pack.json emote "attack" needs a label of 1 to 32 characters', + ); + reject( + "halloween", + [manifest([emote("attack", "l".repeat(33))]), ...files(["attack"])], + 'pack.json emote "attack" needs a label of 1 to 32 characters', + ); + }); + + it("rejects a file that is not .json", () => { + reject( + "halloween", + [manifest([emote("attack", "Attack", "other.json")]), ...files(["attack"])], + 'pack.json emote "attack" must use file "attack.json"', + ); + }); + + it("rejects a listed emote whose file was not uploaded", () => { + reject( + "halloween", + [manifest([emote("attack"), emote("wave")]), ...files(["attack"])], + 'pack.json lists "wave.json" but it was not uploaded', + ); + }); + + it("rejects an uploaded Lottie that pack.json does not list", () => { + reject( + "halloween", + [manifest([emote("attack")]), ...files(["attack", "extra"])], + '"extra.json" is not listed in pack.json', + ); + }); + + it("rejects an emote ref above 48 bytes", () => { + const slug = "s".repeat(30); + const key = "k".repeat(19); + reject( + slug, + [manifest([emote(key)]), ...files([key])], + `emote ref "${slug}/${key}" exceeds 48 bytes`, + ); + }); + + it("accepts an emote ref of exactly 48 bytes", () => { + const slug = "s".repeat(30); + const key = "k".repeat(17); + expect(() => + assertEmotePackFiles(slug, [manifest([emote(key)]), ...files([key])]), + ).not.toThrow(); + }); + + it("validates every Lottie file", () => { + reject( + "halloween", + [manifest([emote("attack")]), { name: "attack.json", bytes: encoder.encode("[]") }], + "attack.json must be a JSON object", + ); + }); +}); diff --git a/tests/unit/modules/catalog/domain/LottieAnimation.test.ts b/tests/unit/modules/catalog/domain/LottieAnimation.test.ts new file mode 100644 index 0000000..22dea94 --- /dev/null +++ b/tests/unit/modules/catalog/domain/LottieAnimation.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "bun:test"; + +import { + assertVectorLottie, + MAX_LOTTIE_BYTES, + MAX_LOTTIE_SECONDS, +} from "../../../../../src/modules/catalog/domain/LottieAnimation"; + +function lottie(overrides: Record = {}): Uint8Array { + return new TextEncoder().encode( + JSON.stringify({ + v: "5.7.4", + fr: 30, + ip: 0, + op: 60, + w: 512, + h: 512, + assets: [], + layers: [{ ty: 4, nm: "shape", shapes: [] }], + ...overrides, + }), + ); +} + +function reject(bytes: Uint8Array, message: string): void { + expect(() => assertVectorLottie("wave.json", bytes)).toThrow(message); +} + +describe("assertVectorLottie", () => { + it("accepts a vector-only animation", () => { + expect(() => assertVectorLottie("wave.json", lottie())).not.toThrow(); + }); + + it("accepts precomposition assets that only hold layers", () => { + const bytes = lottie({ assets: [{ id: "comp_0", layers: [{ ty: 4, shapes: [] }] }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); + + it("rejects a file above the size cap", () => { + reject(new Uint8Array(MAX_LOTTIE_BYTES + 1), "wave.json exceeds 200 KB"); + }); + + it("rejects bytes that are not JSON", () => { + reject(new TextEncoder().encode("{nope"), "wave.json is not valid JSON"); + }); + + it("rejects JSON that is not an object", () => { + reject(new TextEncoder().encode("[]"), "wave.json must be a JSON object"); + }); + + it("rejects a missing or non-string version", () => { + reject(lottie({ v: 5 }), "wave.json must declare a version string in v"); + reject(lottie({ v: "latest" }), "wave.json must declare a version string in v"); + }); + + it("rejects a non-positive frame rate", () => { + reject(lottie({ fr: 0 }), "wave.json requires a frame rate fr greater than 0"); + }); + + it("rejects missing numeric bounds", () => { + reject(lottie({ ip: "0" }), "wave.json requires numeric ip, op, w and h"); + reject(lottie({ w: undefined }), "wave.json requires numeric ip, op, w and h"); + }); + + it("rejects an out point that is not after the in point", () => { + reject(lottie({ ip: 10, op: 10 }), "wave.json requires op greater than ip"); + }); + + it("rejects missing layers", () => { + reject(lottie({ layers: undefined }), "wave.json requires a layers array"); + }); + + it("rejects an animation longer than 3 seconds", () => { + expect(MAX_LOTTIE_SECONDS).toBe(3); + reject(lottie({ fr: 30, ip: 0, op: 91 }), "wave.json lasts longer than 3 seconds"); + }); + + it("accepts an animation of exactly 3 seconds", () => { + expect(() => assertVectorLottie("wave.json", lottie({ fr: 30, ip: 0, op: 90 }))).not.toThrow(); + }); + + it("rejects embedded image assets", () => { + const bytes = lottie({ assets: [{ id: "image_0", p: "data:image/png;base64,AAAA", u: "" }] }); + reject(bytes, "wave.json embeds an image or external asset"); + }); + + it("rejects external asset references", () => { + reject( + lottie({ assets: [{ id: "image_0", u: "https://example.com/" }] }), + "wave.json embeds an image or external asset", + ); + }); + + it("rejects fonts and glyph data", () => { + reject(lottie({ fonts: { list: [] } }), "wave.json must not declare fonts or chars"); + reject(lottie({ chars: [] }), "wave.json must not declare fonts or chars"); + }); + + it("rejects text layers at the root and inside precompositions", () => { + reject(lottie({ layers: [{ ty: 5 }] }), "wave.json contains a text layer"); + reject( + lottie({ assets: [{ id: "comp_0", layers: [{ ty: 5 }] }] }), + "wave.json contains a text layer", + ); + }); + + it("rejects expressions anywhere in the tree", () => { + const bytes = lottie({ + layers: [{ ty: 4, ks: { r: { a: 0, k: 0, x: "time * 10" } }, shapes: [] }], + }); + reject(bytes, "wave.json contains an expression"); + }); + + it("does not mistake a numeric x property for an expression", () => { + const bytes = lottie({ layers: [{ ty: 4, shapes: [{ ty: "rc", x: 3 }] }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); + + it("rejects deeply nested JSON as a validation error instead of overflowing the stack", () => { + const depth = 10_000; + const nested = JSON.parse(`${"[".repeat(depth)}${"]".repeat(depth)}`); + const bytes = lottie({ layers: [{ ty: 4, shapes: nested }] }); + reject(bytes, "wave.json is nested too deeply"); + }); + + it("accepts nesting just inside the depth limit", () => { + const depth = 40; + const nested = JSON.parse(`${"[".repeat(depth)}${"]".repeat(depth)}`); + const bytes = lottie({ layers: [{ ty: 4, shapes: nested }] }); + expect(() => assertVectorLottie("wave.json", bytes)).not.toThrow(); + }); +}); diff --git a/tests/unit/modules/emote-bar/application/GetMyEmoteBar.test.ts b/tests/unit/modules/emote-bar/application/GetMyEmoteBar.test.ts new file mode 100644 index 0000000..3ce1553 --- /dev/null +++ b/tests/unit/modules/emote-bar/application/GetMyEmoteBar.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; + +import { GetMyEmoteBar } from "../../../../../src/modules/emote-bar/application/GetMyEmoteBar"; +import { EmoteBar } from "../../../../../src/modules/emote-bar/domain/EmoteBar"; +import { DEFAULT_EMOTE_IDS } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; +import { barsOf, cosmeticsOf, gatekeeperOf, grant, PACK_ID, pack, signer } from "./fixtures"; + +function barWith(...overrides: Array<[number, string]>): EmoteBar { + const refs = [...DEFAULT_EMOTE_IDS]; + for (const [index, ref] of overrides) refs[index] = ref; + return EmoteBar.create(refs); +} + +function build(options: { + bar: EmoteBar | null; + cosmetics?: ReturnType[]; + grants?: ReturnType[]; +}) { + return new GetMyEmoteBar( + cosmeticsOf(...(options.cosmetics ?? [pack()])), + barsOf(options.bar).repository, + gatekeeperOf(...(options.grants ?? [grant(PACK_ID)])), + signer, + ); +} + +const DEFAULT_SLOT = { packId: null, assetUrl: null, packUrl: null, assetsExpiresAt: null }; + +describe("GetMyEmoteBar", () => { + it("returns the default bar when nothing was saved", async () => { + const result = await build({ bar: null }).run("user-1"); + + expect(result.slots.map((slot) => slot.ref)).toEqual([...DEFAULT_EMOTE_IDS]); + expect(result.slots.every((slot) => slot.packId === null && slot.assetUrl === null)).toBe(true); + }); + + it("carries no URL for default emotes", async () => { + const result = await build({ bar: barWith() }).run("user-1"); + + expect(result.slots[0]).toEqual({ ref: "wave", ...DEFAULT_SLOT }); + }); + + it("signs the emote file and the pack manifest for a pack emote", async () => { + const bar = barWith([2, "halloween/ghost"]); + + const result = await build({ bar }).run("user-1"); + + expect(result.slots[2]).toEqual({ + ref: "halloween/ghost", + packId: PACK_ID, + assetUrl: "signed:emote-packs/halloween/ghost.json", + packUrl: "signed:emote-packs/halloween/pack.json", + assetsExpiresAt: "2030-01-01T00:00:00.000Z", + }); + }); + + it.each([ + ["the pack is inactive", { cosmetics: [pack({ active: false })] }], + ["the pack no longer exists", { cosmetics: [] }], + ["the entitlement is gone", { grants: [] }], + ["the entitlement expired", { grants: [grant(PACK_ID, new Date(Date.now() - 1000))] }], + ["the key left the pack files", { cosmetics: [pack({ assetFiles: ["pack.json"] })] }], + ])("falls back to the default emote of the slot when %s", async (_label, options) => { + const bar = barWith([2, "halloween/ghost"], [5, "halloween/bat"]); + + const result = await build({ bar, ...options }).run("user-1"); + + expect(result.slots[2]).toEqual({ ref: "fire", ...DEFAULT_SLOT }); + expect(result.slots[5]).toEqual({ ref: "mindblown", ...DEFAULT_SLOT }); + }); + + it("keeps usable pack slots when only some are stale", async () => { + const bar = barWith([0, "halloween/ghost"], [1, "gone/emote"]); + + const result = await build({ bar }).run("user-1"); + + expect(result.slots[0].ref).toBe("halloween/ghost"); + expect(result.slots[1]).toEqual({ ref: "thumbsup", ...DEFAULT_SLOT }); + }); + + it("resolves the bar for whichever user id it is asked about", async () => { + const seen: string[] = []; + const bars = barsOf(barWith()); + const useCase = new GetMyEmoteBar( + cosmeticsOf(pack()), + { ...bars.repository, findByUserId: async (id) => (seen.push(id), null) }, + gatekeeperOf(), + signer, + ); + + await useCase.run("someone-else"); + + expect(seen).toEqual(["someone-else"]); + }); +}); diff --git a/tests/unit/modules/emote-bar/application/SaveMyEmoteBar.test.ts b/tests/unit/modules/emote-bar/application/SaveMyEmoteBar.test.ts new file mode 100644 index 0000000..b721d32 --- /dev/null +++ b/tests/unit/modules/emote-bar/application/SaveMyEmoteBar.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "bun:test"; + +import { CosmeticTier } from "../../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; +import { SaveMyEmoteBar } from "../../../../../src/modules/emote-bar/application/SaveMyEmoteBar"; +import { DEFAULT_EMOTE_IDS } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; +import { ForbiddenError } from "../../../../../src/shared/errors/ForbiddenError"; +import { InvalidArgumentError } from "../../../../../src/shared/errors/InvalidArgumentError"; +import { barsOf, cosmeticsOf, gatekeeperOf, grant, PACK_ID, pack } from "./fixtures"; + +function slotsWith(ref: string, index = 0): string[] { + const slots = [...DEFAULT_EMOTE_IDS]; + slots[index] = ref; + return slots; +} + +function build(options: { + cosmetics?: ReturnType[]; + grants?: ReturnType[]; +}) { + const bars = barsOf(null); + const useCase = new SaveMyEmoteBar( + cosmeticsOf(...(options.cosmetics ?? [pack()])), + bars.repository, + gatekeeperOf(...(options.grants ?? [grant(PACK_ID)])), + ); + return { useCase, saved: bars.saved }; +} + +describe("SaveMyEmoteBar", () => { + it("saves a bar of default emotes without looking at packs", async () => { + const { useCase, saved } = build({ cosmetics: [], grants: [] }); + + await useCase.run({ userId: "user-1", slots: [...DEFAULT_EMOTE_IDS].reverse() }); + + expect(saved).toHaveLength(1); + expect(saved[0].userId).toBe("user-1"); + expect(saved[0].bar.refs().map(String)).toEqual([...DEFAULT_EMOTE_IDS].reverse()); + }); + + it("saves an emote of a pack the user holds a grant for", async () => { + const { useCase, saved } = build({}); + const slots = slotsWith("halloween/ghost", 3); + + await useCase.run({ userId: "user-1", slots }); + + expect(saved[0].bar.refs().map(String)).toEqual(slots); + }); + + it("rejects an emote of a pack the user cannot use", async () => { + const { useCase, saved } = build({ grants: [] }); + + await expect( + useCase.run({ userId: "user-1", slots: slotsWith("halloween/ghost") }), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(saved).toHaveLength(0); + }); + + it("rejects an emote of a pack whose grant expired", async () => { + const { useCase } = build({ grants: [grant(PACK_ID, new Date(Date.now() - 1000))] }); + + await expect( + useCase.run({ userId: "user-1", slots: slotsWith("halloween/ghost") }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + it("accepts an emote of a pack the user's tier already covers", async () => { + const { useCase, saved } = build({ + cosmetics: [pack({ tier: CosmeticTier.STANDARD })], + grants: [], + }); + + await useCase.run({ userId: "user-1", slots: slotsWith("halloween/ghost") }); + + expect(saved).toHaveLength(1); + }); + + it.each([ + ["an unknown pack", slotsWith("nope/ghost"), "nope/ghost"], + ["a key missing from the pack", slotsWith("halloween/zombie"), "halloween/zombie"], + ["the manifest as an emote key", slotsWith("halloween/pack"), "halloween/pack"], + ["a malformed ref", slotsWith("Halloween/Ghost"), "Halloween/Ghost"], + ["an unknown default id", slotsWith("dance"), "dance"], + ])("rejects %s naming the ref", async (_label, slots, ref) => { + const { useCase, saved } = build({}); + + const attempt = useCase.run({ userId: "user-1", slots }); + + await expect(attempt).rejects.toBeInstanceOf(InvalidArgumentError); + await expect(attempt).rejects.toThrow(ref); + expect(saved).toHaveLength(0); + }); + + it("rejects an emote of an inactive pack", async () => { + const { useCase } = build({ cosmetics: [pack({ active: false })] }); + + await expect( + useCase.run({ userId: "user-1", slots: slotsWith("halloween/ghost") }), + ).rejects.toBeInstanceOf(InvalidArgumentError); + }); + + it("ignores cosmetics of another type that share the folder name", async () => { + const { useCase } = build({ + cosmetics: [pack({ type: CosmeticType.SLEEVE })], + }); + + await expect( + useCase.run({ userId: "user-1", slots: slotsWith("halloween/ghost") }), + ).rejects.toBeInstanceOf(InvalidArgumentError); + }); + + it("rejects a bar with the wrong size or a repeated emote", async () => { + const { useCase, saved } = build({}); + + await expect(useCase.run({ userId: "user-1", slots: ["wave"] })).rejects.toBeInstanceOf( + InvalidArgumentError, + ); + await expect( + useCase.run({ userId: "user-1", slots: slotsWith("wave", 1) }), + ).rejects.toBeInstanceOf(InvalidArgumentError); + expect(saved).toHaveLength(0); + }); +}); diff --git a/tests/unit/modules/emote-bar/application/fixtures.ts b/tests/unit/modules/emote-bar/application/fixtures.ts new file mode 100644 index 0000000..017e5cf --- /dev/null +++ b/tests/unit/modules/emote-bar/application/fixtures.ts @@ -0,0 +1,72 @@ +import { AssetUrlSigner } from "../../../../../src/modules/assets/domain/AssetUrlSigner"; +import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; +import { CosmeticRepository } from "../../../../../src/modules/catalog/domain/CosmeticRepository"; +import { CosmeticTier } from "../../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; +import { EntitlementsGatekeeper } from "../../../../../src/modules/entitlements/application/EntitlementsGatekeeper"; +import { Entitlement } from "../../../../../src/modules/entitlements/domain/Entitlement"; +import { EntitlementSource } from "../../../../../src/modules/entitlements/domain/EntitlementSource"; +import { GrantType } from "../../../../../src/modules/entitlements/domain/GrantType"; +import { EmoteBar } from "../../../../../src/modules/emote-bar/domain/EmoteBar"; +import { EmoteBarRepository } from "../../../../../src/modules/emote-bar/domain/EmoteBarRepository"; + +export const PACK_ID = "pack-1"; + +export function pack(overrides: Partial[0]> = {}): Cosmetic { + return Cosmetic.from({ + id: PACK_ID, + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.DONOR, + assetRef: "emote-packs/halloween/", + displayName: "Halloween", + active: true, + assetFiles: ["pack.json", "ghost.json", "bat.json"], + ...overrides, + }); +} + +export function grant(cosmeticId: string, expiresAt: Date | null = null): Entitlement { + return Entitlement.create({ + id: `grant-${cosmeticId}`, + userId: "user-1", + grantType: GrantType.COSMETIC, + grantValue: cosmeticId, + source: EntitlementSource.CAMPAIGN, + expiresAt, + }); +} + +export function cosmeticsOf(...items: Cosmetic[]): CosmeticRepository { + return { + findAll: async () => items, + findById: async (id) => items.find((item) => item.id === id) ?? null, + save: async () => undefined, + }; +} + +export function gatekeeperOf(...entitlements: Entitlement[]): EntitlementsGatekeeper { + return new EntitlementsGatekeeper({ + findByUserId: async () => entitlements, + save: async () => undefined, + }); +} + +export function barsOf(initial: EmoteBar | null) { + const saved: Array<{ userId: string; bar: EmoteBar }> = []; + const repository: EmoteBarRepository = { + findByUserId: async () => initial, + save: async (userId, bar) => { + saved.push({ userId, bar }); + }, + }; + return { repository, saved }; +} + +export const signer: AssetUrlSigner = { + sign: () => "", + signMany: () => ({}), + signManifest: async (prefix, files) => ({ + assets: Object.fromEntries((files ?? []).map((file) => [file, `signed:${prefix}${file}`])), + expiresAt: "2030-01-01T00:00:00.000Z", + }), +}; diff --git a/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts b/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts new file mode 100644 index 0000000..5755c7c --- /dev/null +++ b/tests/unit/modules/emote-bar/domain/EmoteBar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; + +import { EmoteBar } from "../../../../../src/modules/emote-bar/domain/EmoteBar"; +import { DEFAULT_EMOTE_IDS } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; + +describe("EmoteBar", () => { + it("defaults to the bundled emotes in order", () => { + expect(EmoteBar.defaults().refs().map(String)).toEqual([...DEFAULT_EMOTE_IDS]); + }); + + it("keeps the order of the refs it is built from", () => { + const refs = ["pack/a", "cry", "pack/b", "wave", "fire", "cool", "sweat", "thumbsup"]; + + expect(EmoteBar.create(refs).refs().map(String)).toEqual(refs); + }); + + it("rejects a bar that does not hold exactly 8 slots", () => { + expect(() => EmoteBar.create(["wave"])).toThrow("Emote bar requires exactly 8 slots"); + expect(() => EmoteBar.create([...DEFAULT_EMOTE_IDS, "pack/a"])).toThrow( + "Emote bar requires exactly 8 slots", + ); + }); + + it("rejects a repeated emote", () => { + const refs = [...DEFAULT_EMOTE_IDS.slice(0, 7), "wave"]; + + expect(() => EmoteBar.create(refs)).toThrow('Emote bar repeats "wave"'); + }); + + it("rejects a malformed ref naming it", () => { + const refs = [...DEFAULT_EMOTE_IDS.slice(0, 7), "Bad/Ref"]; + + expect(() => EmoteBar.create(refs)).toThrow('Invalid emote reference "Bad/Ref"'); + }); +}); diff --git a/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts b/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts new file mode 100644 index 0000000..6cd8b17 --- /dev/null +++ b/tests/unit/modules/emote-bar/domain/EmoteRef.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; + +import { DEFAULT_EMOTE_IDS, EmoteRef } from "../../../../../src/modules/emote-bar/domain/EmoteRef"; + +describe("EmoteRef", () => { + it("lists the bundled default emotes in bar order", () => { + expect(DEFAULT_EMOTE_IDS).toEqual([ + "wave", + "thumbsup", + "fire", + "thinking", + "cool", + "mindblown", + "sweat", + "cry", + ]); + }); + + it("parses a default emote id", () => { + const ref = EmoteRef.parse("fire"); + + expect(ref.isDefault).toBe(true); + expect(ref.packSlug).toBeNull(); + expect(ref.emoteKey).toBeNull(); + expect(ref.toString()).toBe("fire"); + }); + + it("parses a pack emote reference", () => { + const ref = EmoteRef.parse("halloween-duelist/ghost-wave"); + + expect(ref.isDefault).toBe(false); + expect(ref.packSlug).toBe("halloween-duelist"); + expect(ref.emoteKey).toBe("ghost-wave"); + expect(ref.toString()).toBe("halloween-duelist/ghost-wave"); + }); + + it.each([ + "", + "unknown", + "Wave", + "/wave", + "pack/", + "pack/a/b", + "Pack/wave", + "pack/Wave", + "pack_one/wave", + "-pack/wave", + "pack-/wave", + "pack--x/wave", + "pack/a b", + `pack/${"k".repeat(21)}`, + `${"s".repeat(40)}/${"k".repeat(10)}`, + ])("rejects the malformed reference %p", (raw) => { + expect(() => EmoteRef.parse(raw)).toThrow(`Invalid emote reference "${raw}"`); + }); + + it("accepts a key of exactly 20 characters", () => { + expect(() => EmoteRef.parse(`pack/${"k".repeat(20)}`)).not.toThrow(); + }); + + it("accepts a reference of exactly 48 bytes", () => { + const raw = `${"s".repeat(27)}/${"k".repeat(20)}`; + expect(raw.length).toBe(48); + expect(() => EmoteRef.parse(raw)).not.toThrow(); + }); +}); diff --git a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts index d8ac56a..ab3066c 100644 --- a/tests/unit/modules/loadout/application/EquipCosmetic.test.ts +++ b/tests/unit/modules/loadout/application/EquipCosmetic.test.ts @@ -117,6 +117,24 @@ describe("EquipCosmetic", () => { ).rejects.toBeInstanceOf(InvalidArgumentError); }); + it("rejects equipping an EMOTE_PACK because it has no loadout slot", async () => { + const { equip, saved } = build({ + found: Cosmetic.from({ + id: "pack-1", + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.STANDARD, + assetRef: "emote-packs/halloween/", + displayName: "Halloween", + active: true, + }), + }); + + await expect( + equip.run({ userId: "user-1", cosmeticType: CosmeticType.EMOTE_PACK, cosmeticId: "pack-1" }), + ).rejects.toBeInstanceOf(InvalidArgumentError); + expect(saved).toHaveLength(0); + }); + it("equips an AVATAR the user is entitled to", async () => { const { equip, saved } = build({ found: avatar() }); diff --git a/tests/unit/server/routes/emote-bar-router.test.ts b/tests/unit/server/routes/emote-bar-router.test.ts new file mode 100644 index 0000000..13388c5 --- /dev/null +++ b/tests/unit/server/routes/emote-bar-router.test.ts @@ -0,0 +1,155 @@ +import { afterAll, describe, expect, it, spyOn } from "bun:test"; +import { Elysia } from "elysia"; + +import { config } from "../../../../src/config"; +import { Cosmetic } from "../../../../src/modules/catalog/domain/Cosmetic"; +import { CosmeticTier } from "../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../src/modules/catalog/domain/CosmeticType"; +import { CosmeticPostgresRepository } from "../../../../src/modules/catalog/infrastructure/CosmeticPostgresRepository"; +import { EmoteBar } from "../../../../src/modules/emote-bar/domain/EmoteBar"; +import { DEFAULT_EMOTE_IDS } from "../../../../src/modules/emote-bar/domain/EmoteRef"; +import { EmoteBarPostgresRepository } from "../../../../src/modules/emote-bar/infrastructure/EmoteBarPostgresRepository"; +import { Entitlement } from "../../../../src/modules/entitlements/domain/Entitlement"; +import { EntitlementSource } from "../../../../src/modules/entitlements/domain/EntitlementSource"; +import { GrantType } from "../../../../src/modules/entitlements/domain/GrantType"; +import { EntitlementPostgresRepository } from "../../../../src/modules/entitlements/infrastructure/EntitlementPostgresRepository"; +import { UserBanPostgresRepository } from "../../../../src/modules/user/infrastructure/UserBanPostgresRepository"; +import { emoteBarRouter } from "../../../../src/server/routes/emote-bar-router"; +import { AuthenticationError } from "../../../../src/shared/errors/AuthenticationError"; +import { ForbiddenError } from "../../../../src/shared/errors/ForbiddenError"; +import { InvalidArgumentError } from "../../../../src/shared/errors/InvalidArgumentError"; +import { JWT } from "../../../../src/shared/JWT"; +import { UserBanMother } from "../../modules/users/mothers/UserBanMother"; + +// HTTP wiring only: Postgres-backed repositories are replaced with in-memory stand-ins. +// The error mapping mirrors the three entries of Server.onError that this router can reach. +const bannedUserId = "user-banned-1"; +const ownerId = "user-owner-1"; +const strangerId = "user-stranger-1"; + +const pack = Cosmetic.from({ + id: "pack-1", + type: CosmeticType.EMOTE_PACK, + tier: CosmeticTier.DONOR, + assetRef: "emote-packs/halloween/", + displayName: "Halloween", + active: true, + assetFiles: ["pack.json", "ghost.json"], +}); + +let stored = null as EmoteBar | null; +const jwt = new JWT(config.jwt); +const app = new Elysia() + .onError(({ error, set }) => { + if (error instanceof AuthenticationError) set.status = 401; + if (error instanceof ForbiddenError) set.status = 403; + if (error instanceof InvalidArgumentError) set.status = 400; + }) + .use(emoteBarRouter); + +const spies = [ + spyOn(UserBanPostgresRepository.prototype, "findActiveBanByUserId").mockImplementation( + async (userId) => (userId === bannedUserId ? UserBanMother.create({ userId }) : null), + ), + spyOn(CosmeticPostgresRepository.prototype, "findAll").mockImplementation(async () => [pack]), + spyOn(EntitlementPostgresRepository.prototype, "findByUserId").mockImplementation( + async (userId) => + userId === ownerId + ? [ + Entitlement.create({ + id: "e1", + userId, + grantType: GrantType.COSMETIC, + grantValue: pack.id, + source: EntitlementSource.CAMPAIGN, + expiresAt: null, + }), + ] + : [], + ), + spyOn(EmoteBarPostgresRepository.prototype, "findByUserId").mockImplementation( + async () => stored, + ), + spyOn(EmoteBarPostgresRepository.prototype, "save").mockImplementation(async (_id, bar) => { + stored = bar; + }), +]; + +afterAll(() => { + for (const spy of spies) spy.mockRestore(); +}); + +function call(method: string, token?: string, body?: unknown) { + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (body) headers["Content-Type"] = "application/json"; + return app.handle( + new Request("http://localhost/me/emote-bar/", { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }), + ); +} + +const withPackEmote = (): string[] => ["halloween/ghost", ...DEFAULT_EMOTE_IDS.slice(1)]; + +describe("/me/emote-bar", () => { + for (const method of ["GET", "PUT"]) { + it(`answers 401 to ${method} without a token`, async () => { + expect((await call(method, undefined, { slots: [...DEFAULT_EMOTE_IDS] })).status).toBe(401); + }); + + it(`answers 403 to ${method} when the user is banned`, async () => { + const token = jwt.generate({ id: bannedUserId }); + expect((await call(method, token, { slots: [...DEFAULT_EMOTE_IDS] })).status).toBe(403); + }); + } + + it("returns the default bar for a user with no saved bar", async () => { + stored = null; + + const response = await call("GET", jwt.generate({ id: ownerId })); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + const body = (await response.json()) as { slots: { ref: string }[] }; + expect(body.slots.map((slot) => slot.ref)).toEqual([...DEFAULT_EMOTE_IDS]); + }); + + it("saves a bar and answers with the resolved slots", async () => { + stored = null; + + const response = await call("PUT", jwt.generate({ id: ownerId }), { slots: withPackEmote() }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { slots: { ref: string; packId: string | null }[] }; + expect(body.slots[0]).toMatchObject({ ref: "halloween/ghost", packId: "pack-1" }); + expect((stored as EmoteBar | null)?.refs().map(String)).toEqual(withPackEmote()); + }); + + it("answers 403 when saving a pack the user does not own", async () => { + stored = null; + + const response = await call("PUT", jwt.generate({ id: strangerId }), { + slots: withPackEmote(), + }); + + expect(response.status).toBe(403); + expect(stored).toBeNull(); + }); + + it("answers 400 for an unknown emote", async () => { + const response = await call("PUT", jwt.generate({ id: ownerId }), { + slots: ["halloween/zombie", ...DEFAULT_EMOTE_IDS.slice(1)], + }); + + expect(response.status).toBe(400); + }); + + it("rejects a body whose slots are not 8 strings before reaching the use case", async () => { + const response = await call("PUT", jwt.generate({ id: ownerId }), { slots: ["wave"] }); + + expect(response.status).toBe(422); + }); +}); diff --git a/tests/unit/server/swagger/openapi-document.test.ts b/tests/unit/server/swagger/openapi-document.test.ts index 8604a71..a7d8b78 100644 --- a/tests/unit/server/swagger/openapi-document.test.ts +++ b/tests/unit/server/swagger/openapi-document.test.ts @@ -43,6 +43,8 @@ const PROTECTED_OPERATIONS = [ "GET /api/v1/me/cosmetics/{id}/assets", "GET /api/v1/me/loadout/", "PUT /api/v1/me/loadout/", + "GET /api/v1/me/emote-bar/", + "PUT /api/v1/me/emote-bar/", "GET /api/v1/admin/cosmetics/", "POST /api/v1/admin/cosmetics/", "POST /api/v1/admin/cosmetics/{id}/grants",