diff --git a/src/api/Events.test.ts b/src/api/Events.test.ts index 95cc1d9d..c574d5c2 100644 --- a/src/api/Events.test.ts +++ b/src/api/Events.test.ts @@ -3,57 +3,83 @@ import Events from "./Events" describe("Events API", () => { let fetchMock: jest.SpyInstance + beforeEach(() => { + // Clear both caches before each test + ;(Events as any).Cache.clear() + ;(Events as any).liveEventCache.clear() + }) + afterEach(() => { fetchMock?.mockRestore() jest.clearAllMocks() - // Clear cache between tests - ;(Events as any).liveEventCache.clear() }) - describe("when checking if a destination has a live event", () => { - describe("and the API returns live events", () => { - let result: boolean + describe("when checking live events for multiple destinations", () => { + describe("and the API returns live events for some destinations", () => { + let result: Map beforeEach(async () => { fetchMock = jest.spyOn(Events.get(), "fetch").mockResolvedValueOnce({ ok: true, - data: [{ id: "event-1", live: true }], + data: { + events: [ + { id: "event-1", place_id: "place-1", live: true }, + { id: "event-world", place_id: "world-id", live: true }, + ], + total: 2, + }, }) - result = await Events.get().hasLiveEvent("destination-123") + result = await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + "world-id", + ]) + }) + + it("should return true for destinations with live events", () => { + expect(result.get("place-1")).toBe(true) + expect(result.get("world-id")).toBe(true) }) - it("should return true", () => { - expect(result).toBe(true) + it("should return false for destinations without live events", () => { + expect(result.get("place-2")).toBe(false) }) - it("should call the events API with correct parameters", () => { + it("should call the events API with list=live query param", () => { expect(fetchMock).toHaveBeenCalledWith( - "/events?places_ids=destination-123&list=live", + "/events?list=live", expect.anything() ) }) }) describe("and the API returns no live events", () => { - let result: boolean + let result: Map beforeEach(async () => { fetchMock = jest.spyOn(Events.get(), "fetch").mockResolvedValueOnce({ ok: true, - data: [], + data: { + events: [], + total: 0, + }, }) - result = await Events.get().hasLiveEvent("destination-456") + result = await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + ]) }) - it("should return false", () => { - expect(result).toBe(false) + it("should return false for all destinations", () => { + expect(result.get("place-1")).toBe(false) + expect(result.get("place-2")).toBe(false) }) }) describe("and the API call fails", () => { - let result: boolean + let result: Map let consoleErrorSpy: jest.SpyInstance beforeEach(async () => { @@ -62,20 +88,24 @@ describe("Events API", () => { .spyOn(Events.get(), "fetch") .mockRejectedValueOnce(new Error("Network error")) - result = await Events.get().hasLiveEvent("destination-error") + result = await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + ]) }) afterEach(() => { consoleErrorSpy.mockRestore() }) - it("should return false", () => { - expect(result).toBe(false) + it("should return false for all destinations", () => { + expect(result.get("place-1")).toBe(false) + expect(result.get("place-2")).toBe(false) }) it("should log the error", () => { expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error checking live events for destination destination-error:", + "Error checking live events for destinations:", expect.any(Error) ) }) @@ -85,58 +115,91 @@ describe("Events API", () => { beforeEach(async () => { fetchMock = jest.spyOn(Events.get(), "fetch").mockResolvedValueOnce({ ok: true, - data: [{ id: "event-1", live: true }], + data: { + events: [{ id: "event-1", place_id: "place-1", live: true }], + total: 1, + }, }) // First call - should fetch - await Events.get().hasLiveEvent("destination-cached") + await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + ]) // Second call - should use cache - await Events.get().hasLiveEvent("destination-cached") + await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + ]) }) it("should only call the API once", () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) }) - }) - describe("when checking live events for multiple destinations", () => { - let result: Map + describe("and requesting new IDs alongside cached IDs", () => { + let result: Map - beforeEach(async () => { - fetchMock = jest - .spyOn(Events.get(), "fetch") - // First call for place-1 - .mockResolvedValueOnce({ - ok: true, - data: [{ id: "event-1", live: true }], - }) - // Second call for place-2 - .mockResolvedValueOnce({ - ok: true, - data: [], - }) - // Third call for world-id - .mockResolvedValueOnce({ - ok: true, - data: [{ id: "event-world", live: true }], - }) + beforeEach(async () => { + fetchMock = jest + .spyOn(Events.get(), "fetch") + // First call for place-1, place-2 + .mockResolvedValueOnce({ + ok: true, + data: { + events: [{ id: "event-1", place_id: "place-1", live: true }], + total: 1, + }, + }) + // Second call for only place-3 (place-1 and place-2 are cached) + .mockResolvedValueOnce({ + ok: true, + data: { + events: [{ id: "event-3", place_id: "place-3", live: true }], + total: 1, + }, + }) + + // First call - fetch place-1, place-2 + await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + ]) + // Second call - place-1 and place-2 are cached, only fetch place-3 + result = await Events.get().checkLiveEventsForDestinations([ + "place-1", + "place-2", + "place-3", + ]) + }) - result = await Events.get().checkLiveEventsForDestinations([ - "place-1", - "place-2", - "world-id", - ]) - }) + it("should only fetch uncached IDs", () => { + expect(fetchMock).toHaveBeenCalledTimes(2) + }) - it("should return a map with live status for each destination", () => { - expect(result.get("place-1")).toBe(true) - expect(result.get("place-2")).toBe(false) - expect(result.get("world-id")).toBe(true) + it("should return correct live status for all IDs", () => { + expect(result.get("place-1")).toBe(true) // from cache + expect(result.get("place-2")).toBe(false) // from cache + expect(result.get("place-3")).toBe(true) // freshly fetched + }) }) - it("should call the API for each destination", () => { - expect(fetchMock).toHaveBeenCalledTimes(3) + describe("and an empty array is passed", () => { + let result: Map + + beforeEach(async () => { + fetchMock = jest.spyOn(Events.get(), "fetch") + result = await Events.get().checkLiveEventsForDestinations([]) + }) + + it("should return an empty map", () => { + expect(result.size).toBe(0) + }) + + it("should not call the API", () => { + expect(fetchMock).not.toHaveBeenCalled() + }) }) }) }) diff --git a/src/api/Events.ts b/src/api/Events.ts index 84631c52..c054d29c 100644 --- a/src/api/Events.ts +++ b/src/api/Events.ts @@ -23,11 +23,14 @@ export type Event = { export type EventsResponse = { ok: boolean - data: Event[] + data: { + events: Event[] + total: number + } } type CachedLiveStatus = { - hasLiveEvent: boolean + isLive: boolean expiresAt: number } @@ -36,11 +39,11 @@ type CachedLiveStatus = { * Provides methods to check for live events associated with places/worlds. */ export default class Events extends API { - static Url = env("EVENTS_API_URL", "https://events.decentraland.org/api") + static Url = env("EVENTS_API_URL", "https://events.decentraland.zone/api") static Cache = new Map() - // Cache for live event status with 5-minute TTL + // Per-ID cache for live event status with 5-minute TTL private static liveEventCache = new Map() private static readonly CACHE_TTL_MS = Time.Minute * 5 // 5 minutes @@ -56,88 +59,97 @@ export default class Events extends API { } /** - * Check if a destination (place or world) has any live events. - * Both places and worlds use the same `places_ids` filter since they share the same table. - * Results are cached for 5 minutes. + * Batch check for live events for multiple destinations. + * Uses POST with body { placeIds: [...] } as required by the events API. + * Works for both places and worlds since they share the same table. + * Results are cached per-ID for 5 minutes. * - * @param destinationId - The destination UUID (place or world) to check for live events - * @returns true if there's at least one live event, false otherwise + * @param destinationIds - Array of destination UUIDs (places or worlds) to check + * @returns Map where keys are destination IDs and values indicate live event status */ - async hasLiveEvent(destinationId: string): Promise { - const cacheKey = `destination:${destinationId}:live` - const cached = Events.liveEventCache.get(cacheKey) + async checkLiveEventsForDestinations( + destinationIds: string[] + ): Promise> { + const liveEventsMap = new Map() - // Return cached value if it exists and hasn't expired - if (cached && cached.expiresAt > Date.now()) { - return cached.hasLiveEvent + if (destinationIds.length === 0) { + return liveEventsMap + } + + // Check cache for each ID, collect uncached IDs + const uncachedIds: string[] = [] + const now = Date.now() + + for (const id of destinationIds) { + const cached = Events.liveEventCache.get(id) + if (cached && cached.expiresAt > now) { + liveEventsMap.set(id, cached.isLive) + } else { + uncachedIds.push(id) + } + } + + // If all IDs were cached, return early + if (uncachedIds.length === 0) { + return liveEventsMap } const controller = new AbortController() const { signal } = controller - const fetchOptions = new Options({ signal }) + const fetchOptions = new Options({ + signal, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + placeIds: uncachedIds, + }), + }) const timeoutId = setTimeout(() => { controller.abort() }, Time.Second * 10) try { - const params = new URLSearchParams({ - places_ids: destinationId, - list: "live", - }) const response = await this.fetch( - `/events?${params}`, + `/events?list=live`, fetchOptions ) - const hasLiveEvent = - response.ok && response.data && response.data.length > 0 - // Cache the result with expiration - Events.liveEventCache.set(cacheKey, { - hasLiveEvent, - expiresAt: Date.now() + Events.CACHE_TTL_MS, - }) - - return hasLiveEvent + // Initialize uncached IDs as false (no live event) + for (const id of uncachedIds) { + liveEventsMap.set(id, false) + } + + // Mark destinations that have live events + if (response.ok && response.data?.events) { + for (const event of response.data.events) { + if (event.place_id && uncachedIds.includes(event.place_id)) { + liveEventsMap.set(event.place_id, true) + } + } + } + + // Cache each result individually + const expiresAt = now + Events.CACHE_TTL_MS + for (const id of uncachedIds) { + Events.liveEventCache.set(id, { + isLive: liveEventsMap.get(id) ?? false, + expiresAt, + }) + } + + return liveEventsMap } catch (error) { - console.error( - `Error checking live events for destination ${destinationId}:`, - error - ) - return false + console.error(`Error checking live events for destinations:`, error) + // Return false for uncached IDs on error + for (const id of uncachedIds) { + liveEventsMap.set(id, false) + } + return liveEventsMap } finally { clearTimeout(timeoutId) } } - - /** - * Batch check for live events for multiple destinations. - * Works for both places and worlds since they share the same table. - * - * @param destinationIds - Array of destination UUIDs (places or worlds) to check - * @returns Map where keys are destination IDs and values indicate live event status - */ - async checkLiveEventsForDestinations( - destinationIds: string[] - ): Promise> { - const liveEventsMap = new Map() - - const fetchPromises: Promise[] = [] - - for (const id of destinationIds) { - fetchPromises.push( - this.hasLiveEvent(id) - .then((hasLive) => { - liveEventsMap.set(id, hasLive) - }) - .catch(() => { - liveEventsMap.set(id, false) - }) - ) - } - - await Promise.all(fetchPromises) - - return liveEventsMap - } } diff --git a/src/config/local.json b/src/config/local.json index 2aaf7b5a..a2474354 100644 --- a/src/config/local.json +++ b/src/config/local.json @@ -1,6 +1,6 @@ { "GATSBY_CHAIN_ID": "1,137", - "GATSBY_EVENTS_API_URL": "/api", + "GATSBY_EVENTS_API_URL": "https://events.decentraland.zone/api", "GATSBY_DECENTRALAND_URL": "https://play.decentraland.org", "GATSBY_LAND_URL": "https://api.decentraland.org", "GATSBY_PLACES_URL": "/api", diff --git a/src/entities/Destination/routes/getDestinationsList.test.ts b/src/entities/Destination/routes/getDestinationsList.test.ts index 463a46ea..a261558b 100644 --- a/src/entities/Destination/routes/getDestinationsList.test.ts +++ b/src/entities/Destination/routes/getDestinationsList.test.ts @@ -271,7 +271,6 @@ describe("getDestinationsList", () => { beforeEach(() => { mockEventsInstance = { - hasLiveEvent: jest.fn().mockResolvedValue(true), checkLiveEventsForDestinations: jest .fn() .mockImplementation(async (destinationIds: string[]) => { @@ -313,7 +312,6 @@ describe("getDestinationsList", () => { beforeEach(() => { mockEventsInstance = { - hasLiveEvent: jest.fn().mockResolvedValue(false), checkLiveEventsForDestinations: jest .fn() .mockImplementation(async (destinationIds: string[]) => {