diff --git a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts index 8658fb2207..57ba25105b 100644 --- a/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts +++ b/packages/webapp/__tests__/UsersLeaderboardStaticProps.spec.ts @@ -156,6 +156,107 @@ describe('leaderboard static props', () => { }); }); + it('should tolerate partial leaderboard data when a single resolver fails', async () => { + const highestReputation: UserLeaderboard[] = [ + { + score: 500, + user: { + id: 'user-1', + username: 'user-1', + name: 'User One', + image: '', + permalink: '/users/user-1', + createdAt: new Date().toISOString(), + providers: [], + balance: { amount: 0 }, + }, + }, + ]; + + const partialDataError = { + response: { + data: { + ...baseLeaderboardResponse, + highestReputation, + mostAchievementPoints: null, + }, + errors: [{ message: 'Unexpected error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(partialDataError); + } + + if (query === POPULAR_HOT_TAKES_QUERY) { + return Promise.resolve({ popularHotTakes: [] }); + } + + if (query === HIGHEST_LEVEL_QUERY) { + return Promise.resolve({ highestLevel: [] }); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + const result = await getUsersStaticProps(); + + expect(result).toMatchObject({ + props: { + highestReputation, + mostAchievementPoints: [], + }, + revalidate: 3600, + }); + }); + + it('should fail the build when the leaderboard response has no data at all', async () => { + const totalFailure = { + response: { + errors: [{ message: 'Internal server error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(totalFailure); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + await expect(getUsersStaticProps()).rejects.toBe(totalFailure); + }); + + it('should fail the build when every leaderboard field errored', async () => { + const allFieldsFailed = { + response: { + data: { + highestReputation: null, + longestStreak: null, + highestPostViews: null, + mostUpvoted: null, + mostReferrals: null, + mostReadingDays: null, + mostAchievementPoints: null, + mostVerifiedUsers: null, + }, + errors: [{ message: 'Internal server error' }], + }, + }; + + mockRequest.mockImplementation((query: string) => { + if (query === LEADERBOARD_QUERY) { + return Promise.reject(allFieldsFailed); + } + + return Promise.reject(new Error('Unexpected query')); + }); + + await expect(getUsersStaticProps()).rejects.toBe(allFieldsFailed); + }); + it('should return notFound for highest-level detail when schema is missing', async () => { mockRequest.mockRejectedValue( createMissingSchemaError( diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index e2ec4052ba..a06753616b 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -51,6 +51,44 @@ interface PageProps { popularHotTakes: PopularHotTakes[]; } +type LeaderboardQueryData = Omit< + PageProps, + 'highestLevel' | 'isHighestLevelSupported' | 'popularHotTakes' +>; + +const leaderboardListFields: (keyof LeaderboardQueryData)[] = [ + 'highestReputation', + 'longestStreak', + 'highestPostViews', + 'mostUpvoted', + 'mostReferrals', + 'mostReadingDays', + 'mostAchievementPoints', + 'mostVerifiedUsers', +]; + +// A single flaky resolver makes graphql-request throw even though the response +// carries data for every other leaderboard; with hourly ISR an empty section is +// better than failing the whole static export. +const fetchLeaderboards = async (): Promise> => { + try { + return await gqlClient.request(LEADERBOARD_QUERY); + } catch (err) { + const partialData = ( + err as { response?: { data?: Partial } } + )?.response?.data; + const hasUsableData = + partialData && + leaderboardListFields.some((field) => Array.isArray(partialData[field])); + + if (!hasUsableData) { + throw err; + } + + return partialData; + } +}; + const isHighestLevelSchemaMissing = (error: GraphQLError): boolean => { return ( error?.response?.errors?.some( @@ -210,9 +248,7 @@ export async function getStaticProps(): Promise< GetStaticPropsResult > { try { - const res = await gqlClient.request< - Omit - >(LEADERBOARD_QUERY); + const res = await fetchLeaderboards(); let highestLevel: UserLeaderboard[] = []; let isHighestLevelSupported = false; @@ -247,16 +283,16 @@ export async function getStaticProps(): Promise< return { props: { - highestReputation: res.highestReputation, - longestStreak: res.longestStreak, - highestPostViews: res.highestPostViews, - mostUpvoted: res.mostUpvoted, - mostReferrals: res.mostReferrals, - mostReadingDays: res.mostReadingDays, - mostAchievementPoints: res.mostAchievementPoints, + highestReputation: res.highestReputation ?? [], + longestStreak: res.longestStreak ?? [], + highestPostViews: res.highestPostViews ?? [], + mostUpvoted: res.mostUpvoted ?? [], + mostReferrals: res.mostReferrals ?? [], + mostReadingDays: res.mostReadingDays ?? [], + mostAchievementPoints: res.mostAchievementPoints ?? [], highestLevel, isHighestLevelSupported, - mostVerifiedUsers: res.mostVerifiedUsers, + mostVerifiedUsers: res.mostVerifiedUsers ?? [], popularHotTakes, }, revalidate: 3600,