diff --git a/src/modules/emote-bar/application/GetPublicEmoteBar.ts b/src/modules/emote-bar/application/GetPublicEmoteBar.ts new file mode 100644 index 0000000..42df266 --- /dev/null +++ b/src/modules/emote-bar/application/GetPublicEmoteBar.ts @@ -0,0 +1,21 @@ +import { NotFoundError } from "../../../shared/errors/NotFoundError"; +import { UserDirectory } from "../../loadout/domain/UserDirectory"; +import { EmoteBarView, GetMyEmoteBar } from "./GetMyEmoteBar"; + +// Public, read-only emote bar of a player addressed by username — used to play an +// opponent's pack emotes during a duel. A missing username is a 404. +export class GetPublicEmoteBar { + constructor( + private readonly users: UserDirectory, + private readonly emoteBar: Pick, + ) {} + + async run(username: string): Promise { + const userId = await this.users.findUserIdByUsername(username); + if (!userId) { + throw new NotFoundError(`User ${username} not found`); + } + + return this.emoteBar.run(userId); + } +} diff --git a/src/server/routes/public-emote-bar-router.ts b/src/server/routes/public-emote-bar-router.ts new file mode 100644 index 0000000..5624ef0 --- /dev/null +++ b/src/server/routes/public-emote-bar-router.ts @@ -0,0 +1,57 @@ +import { Elysia } from "elysia"; + +import { createR2AssetUrlSigner } from "../../modules/assets/infrastructure/createR2AssetUrlSigner"; +import { CosmeticPostgresRepository } from "../../modules/catalog/infrastructure/CosmeticPostgresRepository"; +import { GetMyEmoteBar } from "../../modules/emote-bar/application/GetMyEmoteBar"; +import { GetPublicEmoteBar } from "../../modules/emote-bar/application/GetPublicEmoteBar"; +import { EmoteBarPostgresRepository } from "../../modules/emote-bar/infrastructure/EmoteBarPostgresRepository"; +import { EmoteBarSchema } from "../../modules/emote-bar/infrastructure/EmoteBarSchemas"; +import { EntitlementsGatekeeper } from "../../modules/entitlements/application/EntitlementsGatekeeper"; +import { EntitlementPostgresRepository } from "../../modules/entitlements/infrastructure/EntitlementPostgresRepository"; +import { UserDirectoryPostgresRepository } from "../../modules/loadout/infrastructure/UserDirectoryPostgresRepository"; +import { errorResponses, jsonOk } from "../openapi/responses"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; + +const getMyEmoteBar = new GetMyEmoteBar( + new CosmeticPostgresRepository(), + new EmoteBarPostgresRepository(), + new EntitlementsGatekeeper(new EntitlementPostgresRepository()), + createR2AssetUrlSigner(), +); +const getPublicEmoteBar = new GetPublicEmoteBar( + new UserDirectoryPostgresRepository(), + getMyEmoteBar, +); + +export const publicEmoteBarRouter = new Elysia().get( + "/users/by-username/:username/emote-bar", + ({ params, set }) => { + preventSignedAssetResponseCaching(set); + return getPublicEmoteBar.run(params.username); + }, + { + detail: { + tags: ["Cosmetics"], + summary: "Get a user's public emote bar by username", + description: + "Public, read-only emote bar of a player addressed by username, with signed asset URLs, so a client can play the opponent's pack emotes. Returns 404 if the username does not exist.", + responses: { + 200: jsonOk(EmoteBarSchema, "Emote bar retrieved successfully", { + 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 }, + ], + }), + ...errorResponses(404), + }, + }, + }, +); diff --git a/src/server/server.ts b/src/server/server.ts index 22449bf..f2e7b80 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -15,6 +15,7 @@ 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 { publicEmoteBarRouter } from "./routes/public-emote-bar-router"; import { publicLoadoutRouter } from "./routes/public-loadout-router"; import { rankedTiersRouter } from "./routes/ranked-tiers-router"; import { adminCosmeticsRouter } from "./routes/admin-cosmetics-router"; @@ -83,6 +84,7 @@ export function mountApiV1Routes(app: Elysia) { .use(emoteBarRouter) .use(loadoutRouter) .use(publicLoadoutRouter) + .use(publicEmoteBarRouter) .use(adminCosmeticsRouter) .use(adminModerationRouter); }); diff --git a/tests/unit/modules/emote-bar/application/GetPublicEmoteBar.test.ts b/tests/unit/modules/emote-bar/application/GetPublicEmoteBar.test.ts new file mode 100644 index 0000000..7c70329 --- /dev/null +++ b/tests/unit/modules/emote-bar/application/GetPublicEmoteBar.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; + +import { GetPublicEmoteBar } from "../../../../../src/modules/emote-bar/application/GetPublicEmoteBar"; +import { EmoteBarView } from "../../../../../src/modules/emote-bar/application/GetMyEmoteBar"; +import { UserDirectory } from "../../../../../src/modules/loadout/domain/UserDirectory"; +import { NotFoundError } from "../../../../../src/shared/errors/NotFoundError"; + +const view: EmoteBarView = { + slots: [{ ref: "thumbsup", packId: null, assetUrl: null, packUrl: null, assetsExpiresAt: null }], +}; + +function build(directory: UserDirectory) { + const resolvedFor: string[] = []; + const useCase = new GetPublicEmoteBar(directory, { + run: async (userId: string) => { + resolvedFor.push(userId); + return view; + }, + }); + return { useCase, resolvedFor }; +} + +describe("GetPublicEmoteBar", () => { + it("resolves the bar of the user found by username", async () => { + const { useCase, resolvedFor } = build({ + findUserIdByUsername: async (username) => (username === "rival" ? "user-rival" : null), + }); + + expect(await useCase.run("rival")).toBe(view); + expect(resolvedFor).toEqual(["user-rival"]); + }); + + it("throws NotFound when the username does not exist", async () => { + const { useCase, resolvedFor } = build({ findUserIdByUsername: async () => null }); + + await expect(useCase.run("ghost")).rejects.toBeInstanceOf(NotFoundError); + expect(resolvedFor).toEqual([]); + }); +}); diff --git a/tests/unit/server/routes/public-emote-bar-router.test.ts b/tests/unit/server/routes/public-emote-bar-router.test.ts new file mode 100644 index 0000000..a585e96 --- /dev/null +++ b/tests/unit/server/routes/public-emote-bar-router.test.ts @@ -0,0 +1,79 @@ +import { afterAll, describe, expect, it, spyOn } from "bun:test"; +import { Elysia } from "elysia"; + +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 { 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 { UserDirectoryPostgresRepository } from "../../../../src/modules/loadout/infrastructure/UserDirectoryPostgresRepository"; +import { publicEmoteBarRouter } from "../../../../src/server/routes/public-emote-bar-router"; +import { NotFoundError } from "../../../../src/shared/errors/NotFoundError"; + +// HTTP wiring only: Postgres-backed repositories are replaced with in-memory stand-ins. +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"], +}); + +const app = new Elysia() + .onError(({ error, set }) => { + if (error instanceof NotFoundError) set.status = 404; + }) + .use(publicEmoteBarRouter); + +const spies = [ + spyOn(UserDirectoryPostgresRepository.prototype, "findUserIdByUsername").mockImplementation( + async (username) => (username === "rival" ? "user-rival" : null), + ), + spyOn(CosmeticPostgresRepository.prototype, "findAll").mockImplementation(async () => [pack]), + spyOn(EntitlementPostgresRepository.prototype, "findByUserId").mockImplementation( + async (userId) => [ + Entitlement.create({ + id: "e1", + userId, + grantType: GrantType.COSMETIC, + grantValue: pack.id, + source: EntitlementSource.CAMPAIGN, + expiresAt: null, + }), + ], + ), + spyOn(EmoteBarPostgresRepository.prototype, "findByUserId").mockImplementation(async () => [ + "halloween/ghost", + ...DEFAULT_EMOTE_IDS.slice(1), + ]), +]; + +afterAll(() => { + for (const spy of spies) spy.mockRestore(); +}); + +const get = (username: string) => + app.handle(new Request(`http://localhost/users/by-username/${username}/emote-bar`)); + +describe("GET /users/by-username/:username/emote-bar", () => { + it("serves the bar of an existing user without a token, with signed URLs uncached", async () => { + const response = await get("rival"); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + const body = (await response.json()) as { slots: { ref: string; packId: string | null }[] }; + expect(body.slots).toHaveLength(DEFAULT_EMOTE_IDS.length); + expect(body.slots[0]).toMatchObject({ ref: "halloween/ghost", packId: "pack-1" }); + }); + + it("answers 404 when the username does not exist", async () => { + expect((await get("ghost")).status).toBe(404); + }); +});