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
81 changes: 81 additions & 0 deletions apps/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
accountIdParamsSchema,
accountReferenceSchema,
encyclopediaListQuerySchema,
entityUpdatesQuerySchema,
heroIdParamsSchema,
identifierSchema,
itemIdParamsSchema,
Expand Down Expand Up @@ -491,6 +492,48 @@ export const registerRoutes = async (
},
),
);
const entityUpdatesResponse = async (
resource: "Hero" | "Item",
entityExists: boolean,
entitySnapshot: StaticDataSnapshot | undefined,
entityId: string,
kinds: Array<"hero" | "item" | "neutral_item">,
query: z.output<typeof entityUpdatesQuerySchema>,
errorMeta: ErrorMeta,
) => {
if (!entitySnapshot) throw staticUnavailableError(resource);
if (!entityExists) {
if (entitySnapshot.quality === "partial") {
throw staticUnavailableError(resource, entitySnapshot);
}
throw new ApiHttpError(
404,
"NOT_FOUND",
`${resource} was not found.`,
false,
createErrorMeta("not_found", null, descriptorFromSnapshot(entitySnapshot)),
);
}

const [releases, updateSnapshot, allReleases] = await Promise.all([
repository.listEntityUpdateReleases(kinds, entityId),
repository.getUpdateSnapshot(),
repository.listUpdateReleases(),
]);
if (!updateSnapshot || (updateSnapshot.quality === "partial" && allReleases.length === 0)) {
throw staticUnavailableError("Update", updateSnapshot);
}
return {
data: paginate(
releases,
query.limit,
query.cursor,
(release) => release.version,
errorMeta,
),
meta: createOperationMeta(descriptorFromSnapshot(updateSnapshot)),
};
};
const playerDescriptor = async (accountId: string): Promise<MetaDescriptor> => {
const [batch, failure, job, profile] = await Promise.all([
repository.getPlayerSyncBatch(accountId),
Expand Down Expand Up @@ -1100,6 +1143,25 @@ export const registerRoutes = async (
};
});

app.get("/v1/heroes/:heroId/updates", async (request) => {
const errorMeta = await defaultErrorMeta();
const { heroId } = parse(heroIdParamsSchema, request.params, errorMeta);
const query = parse(entityUpdatesQuerySchema, request.query, errorMeta);
const [hero, heroSnapshot] = await Promise.all([
repository.getHero(heroId),
repository.getHeroSnapshot(),
]);
return entityUpdatesResponse(
"Hero",
hero !== undefined,
heroSnapshot,
heroId,
["hero"],
query,
errorMeta,
);
});

app.get("/v1/items", async (request) => {
const errorMeta = await defaultErrorMeta();
const query = parse(encyclopediaListQuerySchema, request.query, errorMeta);
Expand Down Expand Up @@ -1151,6 +1213,25 @@ export const registerRoutes = async (
};
});

app.get("/v1/items/:itemId/updates", async (request) => {
const errorMeta = await defaultErrorMeta();
const { itemId } = parse(itemIdParamsSchema, request.params, errorMeta);
const query = parse(entityUpdatesQuerySchema, request.query, errorMeta);
const [item, itemSnapshot] = await Promise.all([
repository.getItem(itemId),
repository.getItemSnapshot(),
]);
return entityUpdatesResponse(
"Item",
item !== undefined,
itemSnapshot,
itemId,
["item", "neutral_item"],
query,
errorMeta,
);
});

app.get("/v1/maps/current", async () => {
const snapshot = await repository.getMapSnapshot();
let map: MapVersion | undefined;
Expand Down
90 changes: 88 additions & 2 deletions apps/api/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
accountResolutionResponseSchema,
apiErrorSchema,
dataStatusResponseSchema,
entityUpdatesResponseSchema,
heroDetailResponseSchema,
heroesResponseSchema,
itemDetailResponseSchema,
Expand Down Expand Up @@ -91,10 +92,16 @@ describe("Dodo API", () => {
heroDetailResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/heroes/1" })),
);
entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/heroes/1/updates" })),
);
itemsResponseSchema.parse(json(await app.inject({ method: "GET", url: "/v1/items" })));
itemDetailResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/items/1" })),
);
entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/items/1/updates" })),
);
mapVersionResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/maps/current" })),
);
Expand All @@ -116,20 +123,99 @@ describe("Dodo API", () => {
json(await app.inject({ method: "GET", url: "/v1/updates?limit=1" })),
);
expect(list.data.items).toHaveLength(1);
expect(list.data.items[0]).toMatchObject({ version: "7.41", changeGroupCount: 1 });
expect(list.data.items[0]).toMatchObject({ version: "7.41", changeGroupCount: 4 });
expect(list.data.items[0]).not.toHaveProperty("groups");

const detail = updateDetailResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/updates/7.41" })),
);
expect(detail.data.groups).toHaveLength(1);
expect(detail.data.groups).toHaveLength(4);
expect(detail.data.groups[0]?.notes[0]?.text).toBe("Deterministic test-only update note.");

const missing = await app.inject({ method: "GET", url: "/v1/updates/does-not-exist" });
expect(missing.statusCode).toBe(404);
expect(apiErrorSchema.parse(json(missing)).error.code).toBe("NOT_FOUND");
});

it("serves filtered hero and item update histories with empty and missing entity semantics", async () => {
const hero = entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/heroes/1/updates" })),
);
expect(hero.data.items).toEqual([
expect.objectContaining({
version: "7.41",
matchedGroupCount: 1,
groups: [expect.objectContaining({ kind: "hero", entityId: "1" })],
}),
]);

const item = entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/items/1/updates" })),
);
expect(item.data.items[0]).toMatchObject({ matchedGroupCount: 2 });
expect(item.data.items[0]?.groups.map((group) => group.kind)).toEqual([
"item",
"neutral_item",
]);

const empty = entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/heroes/2/updates" })),
);
expect(empty.data).toEqual({ items: [], nextCursor: null });

const missing = await app.inject({ method: "GET", url: "/v1/heroes/999/updates" });
expect(missing.statusCode).toBe(404);
expect(apiErrorSchema.parse(json(missing)).error.code).toBe("NOT_FOUND");
});

it("defaults entity update histories to five releases and continues with a stable cursor", async () => {
await app.close();
const repository = await createSeedRepository();
const seedUpdate = await repository.getUpdateRelease("7.41");
const snapshot = await repository.getUpdateSnapshot();
if (!seedUpdate || !snapshot) throw new Error("Seed update fixture missing");
const heroGroup = seedUpdate.groups.find((group) => group.kind === "hero");
if (!heroGroup) throw new Error("Seed hero update group missing");
await repository.replaceUpdateReleases(
Array.from({ length: 6 }, (_, index) => ({
...seedUpdate,
version: `7.41-test-${index}`,
releasedAt: new Date(Date.parse(SEED_UPDATED_AT) + index * 1_000).toISOString(),
changeGroupCount: 1,
groups: [heroGroup],
})),
snapshot,
);
app = await buildApp({ environment: "test", repository });

const first = entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/heroes/1/updates" })),
);
expect(first.data.items.map((release) => release.version)).toEqual([
"7.41-test-5",
"7.41-test-4",
"7.41-test-3",
"7.41-test-2",
"7.41-test-1",
]);
expect(first.data.nextCursor).not.toBeNull();

const second = entityUpdatesResponseSchema.parse(
json(
await app.inject({
method: "GET",
url: `/v1/heroes/1/updates?cursor=${first.data.nextCursor}`,
}),
),
);
expect(second.data.items.map((release) => release.version)).toEqual(["7.41-test-0"]);
expect(second.data.nextCursor).toBeNull();

const invalid = await app.inject({ method: "GET", url: "/v1/heroes/1/updates?limit=11" });
expect(invalid.statusCode).toBe(400);
expect(apiErrorSchema.parse(json(invalid)).error.code).toBe("VALIDATION_ERROR");
});

it("resolves account ID, Steam ID64, and supported profile URLs", async () => {
const cases: InjectOptions[] = [
{
Expand Down
27 changes: 25 additions & 2 deletions apps/api/test/live-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
apiErrorSchema,
dataStatusResponseSchema,
entityUpdatesResponseSchema,
heroDetailResponseSchema,
itemDetailResponseSchema,
mapFeatureTypeSchema,
Expand Down Expand Up @@ -244,6 +245,7 @@ const heroAbilities: CanonicalOfficialHeroAbilityConstants = {
name: "earth_spirit_boulder_smash",
localizedName: "Boulder Smash",
description: "Smashes a target in the direction Earth Spirit is facing.",
attributes: [{ label: "Damage", value: "120 / 180 / 240 / 300" }],
slot: 0,
type: "basic",
},
Expand All @@ -252,6 +254,7 @@ const heroAbilities: CanonicalOfficialHeroAbilityConstants = {
name: "earth_spirit_magnetize",
localizedName: "Magnetize",
description: "Magnetizes nearby enemy units.",
attributes: [{ label: "Duration", value: "6" }],
slot: 1,
type: "ultimate",
},
Expand All @@ -260,6 +263,7 @@ const heroAbilities: CanonicalOfficialHeroAbilityConstants = {
name: "special_bonus_unique_earth_spirit_4",
localizedName: "+150 Rolling Boulder Distance",
description: "",
attributes: [],
slot: 2,
type: "talent",
},
Expand Down Expand Up @@ -555,8 +559,10 @@ describe("live player synchronization", () => {
for (const url of [
"/v1/heroes",
"/v1/heroes/999",
"/v1/heroes/999/updates",
"/v1/items",
"/v1/items/999",
"/v1/items/999/updates",
"/v1/patches",
"/v1/updates",
"/v1/updates/7.41b",
Expand All @@ -575,7 +581,13 @@ describe("live player synchronization", () => {
});
await catalogs.refresh();

for (const url of ["/v1/heroes/999", "/v1/items/999", "/v1/updates/7.40z"]) {
for (const url of [
"/v1/heroes/999",
"/v1/heroes/999/updates",
"/v1/items/999",
"/v1/items/999/updates",
"/v1/updates/7.40z",
]) {
const response = await app.inject({ method: "GET", url });
const error = apiErrorSchema.parse(json(response));
expect(response.statusCode).toBe(404);
Expand Down Expand Up @@ -608,8 +620,19 @@ describe("live player synchronization", () => {
expect((await app.inject({ method: "GET", url: "/v1/heroes" })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/v1/items" })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/v1/updates" })).statusCode).toBe(200);
const partialEntityUpdates = entityUpdatesResponseSchema.parse(
json(await app.inject({ method: "GET", url: "/v1/items/1/updates" })),
);
expect(partialEntityUpdates.data.items).toEqual([]);
expect(partialEntityUpdates.meta.quality).toBe("partial");

for (const url of ["/v1/heroes/999", "/v1/items/999", "/v1/updates/7.40z"]) {
for (const url of [
"/v1/heroes/999",
"/v1/heroes/999/updates",
"/v1/items/999",
"/v1/items/999/updates",
"/v1/updates/7.40z",
]) {
const response = await app.inject({ method: "GET", url });
const error = apiErrorSchema.parse(json(response));
expect(response.statusCode).toBe(503);
Expand Down
Loading
Loading