Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/modules/emote-bar/application/GetPublicEmoteBar.ts
Original file line number Diff line number Diff line change
@@ -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<GetMyEmoteBar, "run">,
) {}

async run(username: string): Promise<EmoteBarView> {
const userId = await this.users.findUserIdByUsername(username);
if (!userId) {
throw new NotFoundError(`User ${username} not found`);
}

return this.emoteBar.run(userId);
}
}
57 changes: 57 additions & 0 deletions src/server/routes/public-emote-bar-router.ts
Original file line number Diff line number Diff line change
@@ -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),
},
},
},
);
2 changes: 2 additions & 0 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,6 +84,7 @@ export function mountApiV1Routes(app: Elysia) {
.use(emoteBarRouter)
.use(loadoutRouter)
.use(publicLoadoutRouter)
.use(publicEmoteBarRouter)
.use(adminCosmeticsRouter)
.use(adminModerationRouter);
});
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/modules/emote-bar/application/GetPublicEmoteBar.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
79 changes: 79 additions & 0 deletions tests/unit/server/routes/public-emote-bar-router.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading