Repository: nodejs/nodejs.dev
Pull request #3206 (2023-02-05 16:14:18)

Feat(node contrib.): use local storage
## Description

Store node contributor in Local storage

## Related Issues

#2963

### Check List

- [X] I have read the [Contributing Guidelines](https://github.com/nodejs/nodejs.dev/blob/main/CONTRIBUTING.md) and made commit messages that follow the guideline.
- [X] I have run `npm run lint:js -- --fix` and/or `npm run lint:md -- --fix` for my JavaScript and/or Markdown changes.
  - This is important as most of the cases your code changes might not be correctly linted
- [X] I have run `npm run test` to check if all tests are passing, and/or `npm run test -- -u` to update snapshots if I created and/or updated React Components.
- [X] I have checked that the build works locally and that `npm run build` work fine.
- [X] I've covered new added functionality with unit tests if necessary.


codecov-commenter: # [Codecov](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Base: **66.02**% // Head: **65.78**% // Decreases project coverage by **`-0.25%`** :warning:
> Coverage data is based on head [(`465f6fa`)](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) compared to base [(`fd57b87`)](https://codecov.io/gh/nodejs/nodejs.dev/commit/fd57b871c2b1ad2f6fb812ed066dec074d76db22?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Patch has no changes to coverable lines.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3206      +/-   ##
==========================================
- Coverage   66.02%   65.78%   -0.25%     
==========================================
  Files         118      145      +27     
  Lines        1351     1736     +385     
  Branches      342      404      +62     
==========================================
+ Hits          892     1142     +250     
- Misses        422      549     +127     
- Partials       37       45       +8     
```


| [Impacted Files](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/templates/blog.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9ibG9nLnRzeA==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [src/hooks/useNodeJsContributorsApi.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2hvb2tzL3VzZU5vZGVKc0NvbnRyaWJ1dG9yc0FwaS50c3g=) | `79.16% <0.00%> (-20.84%)` | :arrow_down: |
| [src/hooks/useDetectOs.ts](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2hvb2tzL3VzZURldGVjdE9zLnRz) | `80.00% <0.00%> (-20.00%)` | :arrow_down: |
| [util-node/getNodeReleasesData.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2dldE5vZGVSZWxlYXNlc0RhdGEuanM=) | `91.30% <0.00%> (-6.32%)` | :arrow_down: |
| [src/\_\_fixtures\_\_/page.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL19fZml4dHVyZXNfXy9wYWdlLnRzeA==) | `94.11% <0.00%> (-5.89%)` | :arrow_down: |
| [src/pages/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3BhZ2VzL2luZGV4LnRzeA==) | `100.00% <0.00%> (ø)` | |
| [src/templates/api.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9hcGkudHN4) | `0.00% <0.00%> (ø)` | |
| [src/templates/learn.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9sZWFybi50c3g=) | `100.00% <0.00%> (ø)` | |
| [util-node/createSlug.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2NyZWF0ZVNsdWcuanM=) | `100.00% <0.00%> (ø)` | |
| [util-node/getNvmData.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2dldE52bURhdGEuanM=) | `100.00% <0.00%> (ø)` | |
| ... and [187 more](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/nodejs/nodejs.dev/pull/3206?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).

github-actions[bot]: Please find a preview at: https://staging.nodejs.dev/3206/
AugustinMauroy: > See my previous comments about using async/await over .then and not mixing the two

I will fix this.
shanpriyan: Successful landing of this PR  🚀 will close #2971 
AugustinMauroy: CI do give a strange error, I can't reproduce locally.
Have you an idea?
ovflowd: IMHO the code looks a little bit-way-too complex for what it is doing, not mention I'm not a fan of mixing try/catch with Promises.

I also believe more testing needs to be done, around:

- if after X times it refreshes/reloads the contributors
- If it handles request failures

Finally, I know it's a nitpick, but I'm not much a fan of how the code was designed (feels bloated). I'd recommend of course tidying the code up, but I'm not exactly sure how to recommend that. I'm fine landing this once the code is at least simplified and more tests are added; as we can always re-iterate the code and improve it.
AugustinMauroy: > and more tests are added

They are no more test I can do.


And for other par I will do my better.
ovflowd: > They are no more test I can do.

yes there are 😅 like I mentioned, the code supposedly is meant to every time you open the page to show a different contributor from the localstorage and remove the previous contributor from there 🤔, and once there are no more contributors in the storage it would refresh the storage.

It is also important to test what happens if the request fails. What will appear on the component, will an error happen?

Hence at least two other unit tests could be added here 👀

This is basically the only hook that fetches async data on runtime right now, so ensuring it works well is fundamental 👀🫡

Patch:
diff --git a/src/__fixtures__/hooks.tsx b/src/__fixtures__/hooks.tsx
index e4357adf..be063a44 100644
--- a/src/__fixtures__/hooks.tsx
+++ b/src/__fixtures__/hooks.tsx
@@ -1,7 +1,7 @@
 import {
   ContributorApiResponse,
   Contributor,
-} from '../hooks/useNodeJsContributorsApi';
+} from '../types/nodejsContributors';
 
 export const createRandomContributorApiData = (): ContributorApiResponse => ({
   login: 'login_mock',
diff --git a/src/hooks/useNodeJsContributorsApi.tsx b/src/hooks/useNodeJsContributorsApi.tsx
index 24ec2a6f..3032194b 100644
--- a/src/hooks/useNodeJsContributorsApi.tsx
+++ b/src/hooks/useNodeJsContributorsApi.tsx
@@ -1,20 +1,28 @@
+/* eslint-disable camelcase */
 import { useEffect, useState } from 'react';
+import type {
+  Contributor,
+  ContributorApiResponse,
+} from '../types/nodejsContributors';
 
-const CONTRIBUTORS_API_URI =
-  'https://api.github.com/repos/nodejs/node/contributors?per_page=1';
+const LIMIT_CONTRIBUTORS = 5;
+const CONTRIBUTORS_API_URI = `https://api.github.com/repos/nodejs/node/contributors?per_page=${LIMIT_CONTRIBUTORS}`;
 
 /**
  * Parses "Link" response header from node/contributors API
  * Returns page values for "next" and "last" API URLs
  * @param linkHeader
  */
-function linkParser(linkHeader: string): {
-  [keu: string]: {
+export function linkParser(linkHeader: string): {
+  [key: string]: {
     url: string;
     page: number;
   };
 } {
-  const regex = /<([^?]+\?per_page=1&[a-z]+=([\d]+))>;[\s]*rel="([a-z]+)"/g;
+  const regex = new RegExp(
+    `<([^?]+\\?per_page=${LIMIT_CONTRIBUTORS}&[a-z]+=([\\d]+))>;[\\s]*rel="([a-z]+)"`,
+    'g'
+  );
   let array: RegExpExecArray | null = null;
   const object = {};
 
@@ -36,7 +44,7 @@ function linkParser(linkHeader: string): {
  * Retrieves max amount of contributors for Node.js main repo.
  * Returns array with random contributor index and max contributors found.
  */
-async function getMaxContributors(): Promise<[number, number]> {
+export async function getMaxContributors(): Promise<[number, number]> {
   const response = await fetch(CONTRIBUTORS_API_URI);
   const linksHeaderValue = response.headers.get('Link');
 
@@ -44,9 +52,8 @@ async function getMaxContributors(): Promise<[number, number]> {
     const links = linkParser(linksHeaderValue);
 
     const randomPage =
-      Math.floor(Math.random() * Math.floor(links.last.page)) + 1;
-
-    return [randomPage, links.last.page];
+      Math.floor(Math.random() * Math.floor(links?.last?.page ?? 1)) + 1;
+    return [randomPage, links?.last?.page ?? 1];
   }
 
   throw new Error('Failed to get amount if max contributors');
@@ -56,27 +63,41 @@ async function getMaxContributors(): Promise<[number, number]> {
  * Retrieves a contributor's object by it's index in API
  * @param randomPage
  */
-async function getContributor(randomPage: number): Promise<Contributor> {
+export async function getContributor(randomPage: number): Promise<Contributor> {
   const response = await fetch(`${CONTRIBUTORS_API_URI}&page=${randomPage}`);
-  const contributor = (await response.json())[0] as ContributorApiResponse;
-
-  return {
-    avatarUri: contributor.avatar_url,
-    commitsListUri: `https://github.com/nodejs/node/commits?author=${contributor.login}`,
-    contributionsCount: contributor.contributions,
-    login: contributor.login,
-    profileUri: contributor.html_url,
-  };
+  const jsonResponse = (await response.json()) as ContributorApiResponse[];
+
+  const contributorData: Contributor[] = jsonResponse.map(
+    ({ avatar_url, login, contributions, html_url }) => ({
+      avatarUri: avatar_url,
+      login,
+      contributionsCount: contributions,
+      profileUri: html_url,
+      commitsListUri: `https://github.com/nodejs/node/commits?author=${login}`,
+    })
+  );
+
+  const contributor = contributorData.shift() as Contributor;
+
+  if (window.localStorage) {
+    window.localStorage.setItem(
+      'contributors',
+      JSON.stringify(contributorData)
+    );
+  }
+
+  return contributor;
 }
 
 /**
  * Calls relative APIs and returns random contributor for Node.js main repo.
  * Trying to store cached data in localStorage in order to do less consequent requests
  */
-async function fetchRandomContributor() {
+export async function fetchRandomContributor(): Promise<Contributor> {
   let maxContributors: number | null = null;
   let fetchDate: number | null = null;
   let needToRefetch = false;
+  let contributors: Contributor[] = [];
   const ONE_MONTH_MS = 2592000000;
 
   if (window.localStorage) {
@@ -84,80 +105,60 @@ async function fetchRandomContributor() {
       window.localStorage.getItem('max_contributors');
     const fetchDateStored = window.localStorage.getItem('fetch_date');
 
+    contributors = JSON.parse(
+      window.localStorage.getItem('contributors') || '[]'
+    );
+
     maxContributors = maxContributorsStored
       ? parseInt(maxContributorsStored, 10)
       : null;
     fetchDate = fetchDateStored ? parseInt(fetchDateStored, 10) : null;
   }
 
-  if (fetchDate && Date.now() - fetchDate >= ONE_MONTH_MS) {
-    needToRefetch = true;
-  }
-
-  // If localStorage and data is less than 1 month old, fetch 1 time
-  try {
-    if (maxContributors && !needToRefetch) {
-      return await getContributor(
+  if (fetchDate && Date.now() - fetchDate >= ONE_MONTH_MS) needToRefetch = true;
+
+  return new Promise((resolve, reject) => {
+    if (contributors && contributors.length > 0 && !needToRefetch) {
+      const contributor = contributors.shift();
+      if (window.localStorage) {
+        localStorage.setItem('contributors', JSON.stringify(contributors));
+      }
+      resolve(contributor as Contributor);
+    } else if (maxContributors && !needToRefetch) {
+      getContributor(
         Math.floor(Math.random() * Math.floor(maxContributors)) + 1
-      );
+      )
+        .then(contributor => resolve(contributor))
+        .catch(error => reject(error));
+    } else {
+      getMaxContributors()
+        .then(([randomPage]) => getContributor(randomPage))
+        .then(contributor => {
+          if (window.localStorage) {
+            window.localStorage.setItem('fetch_date', String(Date.now()));
+          }
+          resolve(contributor);
+        })
+        .catch(error => reject(error));
     }
-
-    const [randomPage, lastPage] = await getMaxContributors();
-
-    const contributor = await getContributor(randomPage);
-
-    if (window.localStorage) {
-      window.localStorage.setItem('fetch_date', String(Date.now()));
-      window.localStorage.setItem('max_contributors', String(lastPage));
-    }
-
-    return contributor;
-  } catch {
-    return null;
-  }
+  });
 }
 
 export function useNodeJsContributorsApi(
   isVisible: boolean
-): Contributor | null {
+): Contributor | null | undefined {
   const [contributor, setContributor] = useState<Contributor | null>(null);
 
   useEffect(() => {
     if (isVisible) {
-      fetchRandomContributor().then(setContributor).catch();
+      fetchRandomContributor()
+        .then(contributorList => {
+          setContributor(contributorList);
+        })
+        .catch(() => {
+          setContributor(null);
+        });
     }
   }, [isVisible]);
-
   return contributor;
 }
-
-export interface ContributorApiResponse {
-  login: string;
-  id: number;
-  url: string;
-  type: string;
-  contributions: number;
-  node_id: string;
-  avatar_url: string;
-  gravatar_id: string;
-  html_url: string;
-  followers_url: string;
-  following_url: string;
-  gists_url: string;
-  starred_url: string;
-  subscriptions_url: string;
-  organizations_url: string;
-  repos_url: string;
-  events_url: string;
-  received_events_url: string;
-  site_admin: boolean;
-  /* eslint-enable camelcase */
-}
-
-export interface Contributor {
-  avatarUri: string;
-  profileUri: string;
-  login: string;
-  contributionsCount: number;
-  commitsListUri: string;
-}
diff --git a/src/types/nodejsContributors.ts b/src/types/nodejsContributors.ts
new file mode 100644
index 00000000..741af80c
--- /dev/null
+++ b/src/types/nodejsContributors.ts
@@ -0,0 +1,29 @@
+export interface ContributorApiResponse {
+  login: string;
+  id: number;
+  url: string;
+  type: string;
+  contributions: number;
+  node_id: string;
+  avatar_url: string;
+  gravatar_id: string;
+  html_url: string;
+  followers_url: string;
+  following_url: string;
+  gists_url: string;
+  starred_url: string;
+  subscriptions_url: string;
+  organizations_url: string;
+  repos_url: string;
+  events_url: string;
+  received_events_url: string;
+  site_admin: boolean;
+}
+
+export interface Contributor {
+  avatarUri: string;
+  profileUri: string;
+  login: string;
+  contributionsCount: number;
+  commitsListUri: string;
+}


Test patch:
diff --git a/src/hooks/__tests__/useNodeJsContributorsApi.test.tsx b/src/hooks/__tests__/useNodeJsContributorsApi.test.tsx
index 3bd20709..75f11c10 100644
--- a/src/hooks/__tests__/useNodeJsContributorsApi.test.tsx
+++ b/src/hooks/__tests__/useNodeJsContributorsApi.test.tsx
@@ -1,148 +1,124 @@
-import React from 'react';
 import fetchMock from 'jest-fetch-mock';
-import { render, screen, waitFor } from '@testing-library/react';
-import { useNodeJsContributorsApi } from '../useNodeJsContributorsApi';
-import { createRandomContributorApiData } from '../../__fixtures__/hooks';
+import {
+  linkParser,
+  getContributor,
+  fetchRandomContributor,
+  getMaxContributors,
+} from '../useNodeJsContributorsApi';
+
+describe('linkParser', () => {
+  it('should parse the Link header correctly', () => {
+    const linkHeader =
+      '<https://api.github.com/repos/nodejs/node/contributors?per_page=5&page=2>; rel="next", <https://api.github.com/repos/nodejs/node/contributors?per_page=5&page=3>; rel="last"';
+    const parsedLinks = linkParser(linkHeader);
+    expect(parsedLinks).toEqual({
+      next: {
+        url: 'https://api.github.com/repos/nodejs/node/contributors?per_page=5&page=2',
+        page: 2,
+      },
+      last: {
+        url: 'https://api.github.com/repos/nodejs/node/contributors?per_page=5&page=3',
+        page: 3,
+      },
+    });
+  });
 
-describe('useNodeJsContributorsApi', () => {
-  const HookRenderer = ({ isVisible }: { isVisible: boolean }): JSX.Element => {
-    const result = useNodeJsContributorsApi(isVisible);
+  it('should return an empty object if the Link header is not present', () => {
+    const linkHeader = '';
+    const parsedLinks = linkParser(linkHeader);
+    expect(parsedLinks).toEqual({});
+  });
+});
 
-    return result === null ? <>null</> : <>{JSON.stringify(result)}</>;
-  };
+describe('getContributor', () => {
+  it('returns a contributor', async () => {
+    const contributor = await getContributor(1);
+    expect(contributor).toHaveProperty('login');
+    expect(contributor).toHaveProperty('avatarUri');
+    expect(contributor).toHaveProperty('contributionsCount');
+    expect(contributor).toHaveProperty('profileUri');
+    expect(contributor).toHaveProperty('commitsListUri');
+  });
 
-  const linkResponseHeaderMock =
-    '<https://api.github.com/repositories/27193779/contributors?per_page=1&page=2>; rel="next", <https://api.github.com/repositories/27193779/contributors?per_page=1&page=438>; rel="last"';
+  it('returns a contributor', async () => {
+    fetchMock.mockRejectOnce(new Error('Failed to fetch'));
 
-  beforeEach(() => {
-    fetchMock.enableMocks();
+    const contributor = await getContributor(4);
+    expect(contributor).toHaveProperty('login');
+    expect(contributor).toHaveProperty('avatarUri');
+    expect(contributor).toHaveProperty('contributionsCount');
+    expect(contributor).toHaveProperty('profileUri');
+    expect(contributor).toHaveProperty('commitsListUri');
   });
 
-  afterEach(() => {
-    fetchMock.resetMocks();
-    localStorage.setItem('max_contributors', '');
-    localStorage.setItem('fetch_date', '');
-  });
+  it('returns a contributor', async () => {
+    fetchMock.mockResponseOnce(JSON.stringify([]));
 
-  it('should return null in case no visible', () => {
-    render(<HookRenderer isVisible={false} />);
-    const linkElement = screen.getByText(/null/i);
-    expect(linkElement).toBeInTheDocument();
+    const contributor = await getContributor(2);
+    expect(contributor).toHaveProperty('login');
+    expect(contributor).toHaveProperty('avatarUri');
+    expect(contributor).toHaveProperty('contributionsCount');
+    expect(contributor).toHaveProperty('profileUri');
+    expect(contributor).toHaveProperty('commitsListUri');
   });
+});
 
-  it('should return null in case no local storage found', async () => {
-    fetchMock.mockResponses(
-      // mock first call to contributors API
-      [
-        '',
-        {
-          headers: {
-            link: linkResponseHeaderMock,
-          },
-        },
-      ],
-      // mock second request and return contributor mocked object
-      JSON.stringify([createRandomContributorApiData()])
-    );
-
-    render(<HookRenderer isVisible />);
-    await waitFor(() => {
-      expect(screen.getByText(/login_mock/i)).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(screen.getByText(/avatar_url_mock/i)).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(fetchMock.mock.calls[0][0]).toBe(
-        'https://api.github.com/repos/nodejs/node/contributors?per_page=1'
-      );
-    });
-    await waitFor(() => {
-      expect(
-        (fetchMock.mock.calls[1][0] as string).includes(
-          'https://api.github.com/repos/nodejs/node/contributors?per_page=1&page='
-        )
-      ).toBe(true);
-    });
+describe('fetchRandomContributor', () => {
+  it('returns a random contributor', async () => {
+    const randomContributor = await fetchRandomContributor();
+    expect(randomContributor).toHaveProperty('login');
+    expect(randomContributor).toHaveProperty('avatarUri');
+    expect(randomContributor).toHaveProperty('contributionsCount');
+    expect(randomContributor).toHaveProperty('profileUri');
+    expect(randomContributor).toHaveProperty('commitsListUri');
   });
 
-  it('should skip retrieving max pages for fresh data in localStorage', async () => {
-    localStorage.setItem('max_contributors', '100');
-    localStorage.setItem('fetch_date', String(Date.now() + 5000));
+  it('returns a random contributor from the local storage', async () => {
+    const randomContributor = await fetchRandomContributor();
+    expect(randomContributor).toHaveProperty('login');
+    expect(randomContributor).toHaveProperty('avatarUri');
+    expect(randomContributor).toHaveProperty('contributionsCount');
+    expect(randomContributor).toHaveProperty('profileUri');
+    expect(randomContributor).toHaveProperty('commitsListUri');
+  });
 
-    fetchMock.mockResponse(JSON.stringify([createRandomContributorApiData()]));
+  it('returns a random contributor from the local storage if the API call fails', async () => {
+    fetchMock.mockRejectOnce(new Error('Failed to fetch'));
 
-    render(<HookRenderer isVisible />);
+    const randomContributor = await fetchRandomContributor();
+    expect(randomContributor).toHaveProperty('login');
+    expect(randomContributor).toHaveProperty('avatarUri');
+    expect(randomContributor).toHaveProperty('contributionsCount');
+    expect(randomContributor).toHaveProperty('profileUri');
+    expect(randomContributor).toHaveProperty('commitsListUri');
+  });
 
-    await waitFor(() => {
-      expect(screen.getByText(/login_mock/i)).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(screen.getByText(/avatar_url_mock/i)).toBeInTheDocument();
-    });
+  it('returns a random contributor from the local storage if the API call returns an empty array', async () => {
+    fetchMock.mockResponseOnce(JSON.stringify([]));
 
-    // expect first call will be to random page instead of extra request for
-    // getting max pages
-    await waitFor(() => {
-      expect(
-        (fetchMock.mock.calls[0][0] as string).includes(
-          'https://api.github.com/repos/nodejs/node/contributors?per_page=1&page='
-        )
-      ).toBe(true);
-    });
+    const randomContributor = await fetchRandomContributor();
+    expect(randomContributor).toHaveProperty('login');
+    expect(randomContributor).toHaveProperty('avatarUri');
+    expect(randomContributor).toHaveProperty('contributionsCount');
+    expect(randomContributor).toHaveProperty('profileUri');
+    expect(randomContributor).toHaveProperty('commitsListUri');
   });
+});
 
-  it('should refetch expired data', async () => {
-    localStorage.setItem('fetch_date', '1');
-
-    fetchMock.mockResponses(
-      // mock first call to contributors API
-      [
-        '',
-        {
-          headers: {
-            link: linkResponseHeaderMock,
-          },
-        },
-      ],
-      // mock second request and return contributor mocked object
-      JSON.stringify([createRandomContributorApiData()])
-    );
-
-    render(<HookRenderer isVisible />);
-    await waitFor(() => {
-      expect(screen.getByText(/login_mock/i)).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(screen.getByText(/avatar_url_mock/i)).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(fetchMock.mock.calls[0][0]).toBe(
-        'https://api.github.com/repos/nodejs/node/contributors?per_page=1'
-      );
-    });
-    await waitFor(() => {
-      expect(
-        (fetchMock.mock.calls[1][0] as string).includes(
-          'https://api.github.com/repos/nodejs/node/contributors?per_page=1&page='
-        )
-      ).toBe(true);
-    });
+describe('getMaxContributors', () => {
+  it('returns the max number of contributors and the page number', async () => {
+    const maxContributors = await getMaxContributors();
+    expect(maxContributors).toHaveLength(2);
+    expect(maxContributors[0]).toBeGreaterThan(0);
+    expect(maxContributors[1]).toBeGreaterThan(0);
   });
 
-  describe('error handling', () => {
-    it('should return null on error', async () => {
-      fetchMock.mockReject(new Error('fake error message'));
-      render(<HookRenderer isVisible />);
-      const linkElement = screen.getByText(/null/i);
-      expect(linkElement).toBeInTheDocument();
-    });
+  it('returns the max number of contributors and the page number', async () => {
+    fetchMock.mockRejectOnce(new Error('Failed to fetch'));
 
-    it('should return null on unexpected link header from API', async () => {
-      fetchMock.mockResponseOnce('');
-      render(<HookRenderer isVisible />);
-      const linkElement = screen.getByText(/null/i);
-      expect(linkElement).toBeInTheDocument();
-    });
+    const maxContributors = await getMaxContributors();
+    expect(maxContributors).toHaveLength(2);
+    expect(maxContributors[0]).toBeGreaterThan(0);
+    expect(maxContributors[1]).toBeGreaterThan(0);
   });
 });