Repository: gitify-app/gitify
Pull request #845 (2024-02-29 20:51:07)

refactor: extract state notification logic
Closes: #829

Extracts the notification state logic into it's own file.  Also refactored the test suite to be clearer on the scenarios being tested


Patch:
diff --git a/src/__mocks__/mockedData.ts b/src/__mocks__/mockedData.ts
index 24f87f73..cfa1ef9b 100644
--- a/src/__mocks__/mockedData.ts
+++ b/src/__mocks__/mockedData.ts
@@ -497,83 +497,3 @@ export const mockedGraphQLResponse: GraphQLSearch<DiscussionSearchResultEdge> =
       },
     },
   };
-
-export const mockedDiscussionNotifications = [
-  {
-    id: 1,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is an answered discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 2,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is a duplicate discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 3,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is an open discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 4,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is nm outdated discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 5,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is a reopened discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 6,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is a resolved discussion',
-      type: 'Discussion',
-    },
-  },
-  {
-    id: 7,
-    updated_at: '2024-02-26T00:00:00Z',
-    repository: {
-      full_name: 'some/repo',
-    },
-    subject: {
-      title: 'This is a default discussion',
-      type: 'Discussion',
-    },
-  },
-];
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 4d875a44..82ca9f4d 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -9,7 +9,6 @@ import {
   getEnterpriseAccountToken,
   generateGitHubAPIUrl,
   isEnterpriseHost,
-  getDiscussionState,
 } from '../utils/helpers';
 import { removeNotification } from '../utils/remove-notification';
 import {
@@ -18,6 +17,7 @@ import {
 } from '../utils/notifications';
 import Constants from '../utils/constants';
 import { removeNotifications } from '../utils/remove-notifications';
+import { getNotificationState } from '../utils/state';
 
 interface NotificationsState {
   notifications: AccountNotifications[];
@@ -141,47 +141,18 @@ export const useNotifications = (colors: boolean): NotificationsState => {
                               )
                             : accounts.token;
 
-                          switch (notification.subject.type) {
-                            case 'Discussion':
-                              const discussionState = await getDiscussionState(
-                                notification,
-                                token,
-                              );
-
-                              return {
-                                ...notification,
-                                subject: {
-                                  ...notification.subject,
-                                  state: discussionState,
-                                },
-                              };
-                            case 'Issue':
-                            case 'PullRequest':
-                              const cardinalData = (
-                                await apiRequestAuth(
-                                  notification.subject.url,
-                                  'GET',
-                                  token,
-                                )
-                              ).data;
-
-                              const state =
-                                cardinalData.state === 'closed'
-                                  ? cardinalData.state_reason ||
-                                    (cardinalData.merged && 'merged') ||
-                                    'closed'
-                                  : (cardinalData.draft && 'draft') || 'open';
-
-                              return {
-                                ...notification,
-                                subject: {
-                                  ...notification.subject,
-                                  state,
-                                },
-                              };
-                            default:
-                              return notification;
-                          }
+                          const notificationState = await getNotificationState(
+                            notification,
+                            token,
+                          );
+
+                          return {
+                            ...notification,
+                            subject: {
+                              ...notification.subject,
+                              state: notificationState,
+                            },
+                          };
                         },
                       ),
                     ),
diff --git a/src/typesGithub.ts b/src/typesGithub.ts
index d7b18de7..68548f20 100644
--- a/src/typesGithub.ts
+++ b/src/typesGithub.ts
@@ -35,18 +35,20 @@ export type SubjectType =
   | 'RepositoryVulnerabilityAlert'
   | 'WorkflowRun';
 
-export type IssueStateType =
-  | 'closed'
-  | 'completed'
-  | 'not_planned'
-  | 'open'
-  | 'reopened';
+export type IssueStateType = 'closed' | 'open';
+
+export type IssueStateReasonType = 'completed' | 'not_planned' | 'reopened';
 
+/**
+ * Note: draft and merged are not official states in the GitHub API.
+ * These are derived from the pull request's `merged` and `draft` properties.
+ */
 export type PullRequestStateType = 'closed' | 'draft' | 'merged' | 'open';
 
 export type StateType =
   | DiscussionStateType
   | IssueStateType
+  | IssueStateReasonType
   | PullRequestStateType;
 
 export type ViewerSubscription = 'IGNORED' | 'SUBSCRIBED' | 'UNSUBSCRIBED';
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index d343f7b1..2ccdbe9b 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -3,8 +3,6 @@ import {
   Notification,
   GraphQLSearch,
   DiscussionCommentEdge,
-  DiscussionStateType,
-  DiscussionStateSearchResultEdge,
   DiscussionSearchResultEdge,
 } from '../typesGithub';
 import { apiRequestAuth } from '../utils/api-requests';
@@ -92,7 +90,6 @@ async function getDiscussionUrl(
                       viewerSubscription
                       title
                       url
-                      stateReason
                       comments(last: 100) {
                         edges {
                           node {
@@ -139,53 +136,6 @@ async function getDiscussionUrl(
   return url;
 }
 
-export async function getDiscussionState(
-  notification: Notification,
-  token: string,
-): Promise<DiscussionStateType> {
-  const response: GraphQLSearch<DiscussionStateSearchResultEdge> =
-    await apiRequestAuth(`https://api.github.com/graphql`, 'POST', token, {
-      query: `{
-        search(query:"${formatSearchQueryString(
-          notification.repository.full_name,
-          notification.subject.title,
-          notification.updated_at,
-        )}", type: DISCUSSION, first: 10) {
-          edges {
-            node {
-              ... on Discussion {
-                viewerSubscription
-                title
-                stateReason  
-                isAnswered
-              }
-            }
-          }
-        }
-      }`,
-    });
-  let edges =
-    response?.data?.data?.search?.edges?.filter(
-      (edge) => edge.node.title === notification.subject.title,
-    ) || [];
-  if (edges.length > 1)
-    edges = edges.filter(
-      (edge) => edge.node.viewerSubscription === 'SUBSCRIBED',
-    );
-
-  if (edges[0]) {
-    if (edges[0].node.isAnswered) {
-      return 'ANSWERED';
-    }
-
-    if (edges[0].node.stateReason) {
-      return edges[0].node.stateReason;
-    }
-  }
-
-  return 'OPEN';
-}
-
 export const getLatestDiscussionCommentId = (
   comments: DiscussionCommentEdge[],
 ) =>
diff --git a/src/utils/state.ts b/src/utils/state.ts
new file mode 100644
index 00000000..fed46baa
--- /dev/null
+++ b/src/utils/state.ts
@@ -0,0 +1,103 @@
+import { formatSearchQueryString } from './helpers';
+import {
+  DiscussionStateSearchResultEdge,
+  DiscussionStateType,
+  GraphQLSearch,
+  IssueStateType,
+  Notification,
+  PullRequestStateType,
+  StateType,
+} from '../typesGithub';
+import { apiRequestAuth } from './api-requests';
+
+export async function getNotificationState(
+  notification: Notification,
+  token: string,
+): Promise<StateType> {
+  switch (notification.subject.type) {
+    case 'Discussion':
+      return await getDiscussionState(notification, token);
+    case 'Issue':
+      return await getIssueState(notification, token);
+    case 'PullRequest':
+      return await getPullRequestState(notification, token);
+    default:
+      return null;
+  }
+}
+
+export async function getDiscussionState(
+  notification: Notification,
+  token: string,
+): Promise<DiscussionStateType> {
+  const response: GraphQLSearch<DiscussionStateSearchResultEdge> =
+    await apiRequestAuth(`https://api.github.com/graphql`, 'POST', token, {
+      query: `{
+          search(query:"${formatSearchQueryString(
+            notification.repository.full_name,
+            notification.subject.title,
+            notification.updated_at,
+          )}", type: DISCUSSION, first: 10) {
+            edges {
+              node {
+                ... on Discussion {
+                  viewerSubscription
+                  title
+                  stateReason  
+                  isAnswered
+                }
+              }
+            }
+          }
+        }`,
+    });
+
+  let edges =
+    response?.data?.data?.search?.edges?.filter(
+      (edge) => edge.node.title === notification.subject.title,
+    ) || [];
+
+  if (edges.length > 1) {
+    edges = edges.filter(
+      (edge) => edge.node.viewerSubscription === 'SUBSCRIBED',
+    );
+  }
+
+  if (edges[0]) {
+    if (edges[0].node.isAnswered) {
+      return 'ANSWERED';
+    }
+
+    if (edges[0].node.stateReason) {
+      return edges[0].node.stateReason;
+    }
+  }
+
+  return 'OPEN';
+}
+
+export async function getIssueState(
+  notification: Notification,
+  token: string,
+): Promise<IssueStateType> {
+  const issue = (await apiRequestAuth(notification.subject.url, 'GET', token))
+    .data;
+
+  return issue.state_reason ?? issue.state;
+}
+
+export async function getPullRequestState(
+  notification: Notification,
+  token: string,
+): Promise<PullRequestStateType> {
+  const pr = (await apiRequestAuth(notification.subject.url, 'GET', token))
+    .data;
+
+  if (pr.merged) {
+    return 'merged';
+  } else if (pr.draft) {
+    return 'draft';
+  }
+
+  return pr.state;
+}


Test patch:
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
index 4285d006..42c07b6b 100644
--- a/src/hooks/useNotifications.test.ts
+++ b/src/hooks/useNotifications.test.ts
@@ -3,10 +3,7 @@ import axios from 'axios';
 import nock from 'nock';
 
 import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
-import {
-  mockedDiscussionNotifications,
-  mockedUser,
-} from '../__mocks__/mockedData';
+import { mockedUser } from '../__mocks__/mockedData';
 import { AuthState } from '../types';
 import { useNotifications } from './useNotifications';
 
@@ -195,23 +192,27 @@ describe('hooks/useNotifications.ts', () => {
           {
             id: 1,
             subject: {
-              title: 'This is a notification.',
-              type: 'Issue',
+              title: 'This is a Discussion.',
+              type: 'Discussion',
               url: 'https://api.github.com/1',
             },
+            repository: {
+              full_name: 'some/repo',
+            },
+            updated_at: '2024-02-26T00:00:00Z',
           },
           {
             id: 2,
             subject: {
-              title: 'A merged PR.',
-              type: 'PullRequest',
+              title: 'This is an Issue.',
+              type: 'Issue',
               url: 'https://api.github.com/2',
             },
           },
           {
             id: 3,
             subject: {
-              title: 'A closed PR.',
+              title: 'This is a Pull Request.',
               type: 'PullRequest',
               url: 'https://api.github.com/3',
             },
@@ -219,83 +220,17 @@ describe('hooks/useNotifications.ts', () => {
           {
             id: 4,
             subject: {
-              title: 'A draft PR.',
-              type: 'PullRequest',
+              title: 'This is an invitation.',
+              type: 'RepositoryInvitation',
               url: 'https://api.github.com/4',
             },
           },
-          {
-            id: 5,
-            subject: {
-              title: 'A draft PR.',
-              type: 'PullRequest',
-              url: 'https://api.github.com/5',
-            },
-          },
         ];
 
         nock('https://api.github.com')
           .get('/notifications?participating=false')
           .reply(200, notifications);
 
-        nock('https://api.github.com').get('/1').reply(200, { state: 'open' });
-        nock('https://api.github.com')
-          .get('/2')
-          .reply(200, { state: 'closed', merged: true });
-        nock('https://api.github.com')
-          .get('/3')
-          .reply(200, { state: 'closed', merged: false });
-        nock('https://api.github.com')
-          .get('/4')
-          .reply(200, { state: 'open', draft: false });
-        nock('https://api.github.com')
-          .get('/5')
-          .reply(200, { state: 'open', draft: true });
-
-        const { result } = renderHook(() => useNotifications(true));
-
-        act(() => {
-          result.current.fetchNotifications(accounts, {
-            ...mockSettings,
-            colors: true,
-          });
-        });
-
-        expect(result.current.isFetching).toBe(true);
-
-        await waitFor(() => {
-          expect(result.current.notifications[0].hostname).toBe('github.com');
-        });
-
-        expect(result.current.notifications[0].notifications.length).toBe(5);
-        expect(
-          result.current.notifications[0].notifications[0].subject.state,
-        ).toBe('open');
-        expect(
-          result.current.notifications[0].notifications[1].subject.state,
-        ).toBe('merged');
-        expect(
-          result.current.notifications[0].notifications[2].subject.state,
-        ).toBe('closed');
-        expect(
-          result.current.notifications[0].notifications[3].subject.state,
-        ).toBe('open');
-        expect(
-          result.current.notifications[0].notifications[4].subject.state,
-        ).toBe('draft');
-      });
-
-      it('should fetch discussion notifications with success - with colors', async () => {
-        const accounts: AuthState = {
-          ...mockAccounts,
-          enterpriseAccounts: [],
-          user: mockedUser,
-        };
-
-        nock('https://api.github.com')
-          .get('/notifications?participating=false')
-          .reply(200, mockedDiscussionNotifications);
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
@@ -313,117 +248,19 @@ describe('hooks/useNotifications.ts', () => {
                 ],
               },
             },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'This is a duplicate discussion',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: 'DUPLICATE',
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'This is an open discussion',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: null,
-                      isAnswered: false,
-                    },
-                  },
-                  {
-                    node: {
-                      title: 'This is an open discussion',
-                      viewerSubscription: 'IGNORED',
-                      stateReason: null,
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'This is nm outdated discussion',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: 'OUTDATED',
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'This is a reopened discussion',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: 'REOPENED',
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'This is a resolved discussion',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: 'RESOLVED',
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
-          })
-          .post('/graphql')
-          .reply(200, {
-            data: {
-              search: {
-                edges: [
-                  {
-                    node: {
-                      title: 'unknown search result',
-                      viewerSubscription: 'SUBSCRIBED',
-                      stateReason: null,
-                      isAnswered: false,
-                    },
-                  },
-                ],
-              },
-            },
           });
+        nock('https://api.github.com')
+          .get('/2')
+          .reply(200, { state: 'closed', merged: true });
+        nock('https://api.github.com')
+          .get('/3')
+          .reply(200, { state: 'closed', merged: false });
+        nock('https://api.github.com')
+          .get('/4')
+          .reply(200, { state: 'open', draft: false });
+        nock('https://api.github.com')
+          .get('/5')
+          .reply(200, { state: 'open', draft: true });
 
         const { result } = renderHook(() => useNotifications(true));
 
@@ -440,26 +277,7 @@ describe('hooks/useNotifications.ts', () => {
           expect(result.current.notifications[0].hostname).toBe('github.com');
         });
 
-        const resultNotifications = result.current.notifications[0];
-
-        expect(resultNotifications.notifications.length).toBe(7);
-        expect(resultNotifications.notifications[0].subject.state).toBe(
-          'ANSWERED',
-        );
-        expect(resultNotifications.notifications[1].subject.state).toBe(
-          'DUPLICATE',
-        );
-        expect(resultNotifications.notifications[2].subject.state).toBe('OPEN');
-        expect(resultNotifications.notifications[3].subject.state).toBe(
-          'OUTDATED',
-        );
-        expect(resultNotifications.notifications[4].subject.state).toBe(
-          'REOPENED',
-        );
-        expect(resultNotifications.notifications[5].subject.state).toBe(
-          'RESOLVED',
-        );
-        expect(resultNotifications.notifications[6].subject.state).toBe('OPEN');
+        expect(result.current.notifications[0].notifications.length).toBe(4);
       });
     });
   });
diff --git a/src/utils/__snapshots__/api-requests.test.ts.snap b/src/utils/__snapshots__/api-requests.test.ts.snap
index 3283b876..86a0ee8e 100644
--- a/src/utils/__snapshots__/api-requests.test.ts.snap
+++ b/src/utils/__snapshots__/api-requests.test.ts.snap
@@ -8,6 +8,14 @@ exports[`apiRequest should make a request with the correct parameters 1`] = `
 }
 `;
 
+exports[`apiRequest should make a request with the correct parameters and default data 1`] = `
+{
+  "Accept": "application/json",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
 exports[`apiRequestAuth should make an authenticated request with the correct parameters 1`] = `
 {
   "Accept": "application/json",
@@ -16,3 +24,12 @@ exports[`apiRequestAuth should make an authenticated request with the correct pa
   "Content-Type": "application/json",
 }
 `;
+
+exports[`apiRequestAuth should make an authenticated request with the correct parameters and default data 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
diff --git a/src/utils/api-requests.test.ts b/src/utils/api-requests.test.ts
index 63558825..9bb71648 100644
--- a/src/utils/api-requests.test.ts
+++ b/src/utils/api-requests.test.ts
@@ -3,10 +3,11 @@ import { apiRequest, apiRequestAuth } from './api-requests';
 
 jest.mock('axios');
 
+const url = 'https://example.com';
+const method = 'get';
+
 describe('apiRequest', () => {
   it('should make a request with the correct parameters', async () => {
-    const url = 'https://example.com';
-    const method = 'get';
     const data = { key: 'value' };
 
     await apiRequest(url, method, data);
@@ -19,13 +20,25 @@ describe('apiRequest', () => {
 
     expect(axios.defaults.headers.common).toMatchSnapshot();
   });
+
+  it('should make a request with the correct parameters and default data', async () => {
+    const data = {};
+    await apiRequest(url, method);
+
+    expect(axios).toHaveBeenCalledWith({
+      method,
+      url,
+      data,
+    });
+
+    expect(axios.defaults.headers.common).toMatchSnapshot();
+  });
 });
 
 describe('apiRequestAuth', () => {
+  const token = 'yourAuthToken';
+
   it('should make an authenticated request with the correct parameters', async () => {
-    const url = 'https://example.com';
-    const method = 'get';
-    const token = 'yourAuthToken';
     const data = { key: 'value' };
 
     await apiRequestAuth(url, method, token, data);
@@ -38,4 +51,18 @@ describe('apiRequestAuth', () => {
 
     expect(axios.defaults.headers.common).toMatchSnapshot();
   });
+
+  it('should make an authenticated request with the correct parameters and default data', async () => {
+    const data = {};
+
+    await apiRequestAuth(url, method, token);
+
+    expect(axios).toHaveBeenCalledWith({
+      method,
+      url,
+      data,
+    });
+
+    expect(axios.defaults.headers.common).toMatchSnapshot();
+  });
 });
diff --git a/src/utils/state.test.ts b/src/utils/state.test.ts
new file mode 100644
index 00000000..8cc7c06b
--- /dev/null
+++ b/src/utils/state.test.ts
@@ -0,0 +1,402 @@
+import axios from 'axios';
+import nock from 'nock';
+
+import { mockAccounts } from '../__mocks__/mock-state';
+import { mockedSingleNotification } from '../__mocks__/mockedData';
+import {
+  getDiscussionState,
+  getIssueState,
+  getPullRequestState,
+} from './state';
+describe('utils/state.ts', () => {
+  beforeEach(() => {
+    // axios will default to using the XHR adapter which can't be intercepted
+    // by nock. So, configure axios to use the node adapter.
+    axios.defaults.adapter = 'http';
+  });
+
+  describe('getDiscussionState', () => {
+    it('answered discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is an answered discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is an answered discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: null,
+                    isAnswered: true,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('ANSWERED');
+    });
+
+    it('duplicate discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is a duplicate discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is a duplicate discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: 'DUPLICATE',
+                    isAnswered: false,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('DUPLICATE');
+    });
+
+    it('open discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is an open discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is an open discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: null,
+                    isAnswered: false,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('OPEN');
+    });
+
+    it('outdated discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is an outdated discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is an outdated discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: 'OUTDATED',
+                    isAnswered: false,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('OUTDATED');
+    });
+
+    it('reopened discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is a reopened discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is a reopened discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: 'REOPENED',
+                    isAnswered: false,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('REOPENED');
+    });
+
+    it('resolved discussion state', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is a resolved discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is a resolved discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: 'RESOLVED',
+                    isAnswered: false,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('RESOLVED');
+    });
+
+    it('filtered response by subscribed', async () => {
+      const mockNotification = {
+        ...mockedSingleNotification,
+        subject: {
+          ...mockedSingleNotification.subject,
+          title: 'This is a discussion',
+        },
+      };
+
+      nock('https://api.github.com')
+        .post('/graphql')
+        .reply(200, {
+          data: {
+            search: {
+              edges: [
+                {
+                  node: {
+                    title: 'This is a discussion',
+                    viewerSubscription: 'SUBSCRIBED',
+                    stateReason: null,
+                    isAnswered: false,
+                  },
+                },
+                {
+                  node: {
+                    title: 'This is a discussion',
+                    viewerSubscription: 'IGNORED',
+                    stateReason: null,
+                    isAnswered: true,
+                  },
+                },
+              ],
+            },
+          },
+        });
+
+      const result = await getDiscussionState(
+        mockNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('OPEN');
+    });
+
+    it('handles unknown or missing results', async () => {});
+  });
+
+  describe('getIssueState', () => {
+    it('open issue state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open' });
+
+      const result = await getIssueState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('open');
+    });
+
+    it('closed issue state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'closed' });
+
+      const result = await getIssueState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('closed');
+    });
+
+    it('completed issue state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'closed', state_reason: 'completed' });
+
+      const result = await getIssueState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('completed');
+    });
+
+    it('not_planned issue state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open', state_reason: 'not_planned' });
+
+      const result = await getIssueState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('not_planned');
+    });
+
+    it('reopened issue state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open', state_reason: 'reopened' });
+
+      const result = await getIssueState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('reopened');
+    });
+  });
+
+  describe('getPullRequestState', () => {
+    it('closed pull request state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'closed', draft: false, merged: false });
+
+      const result = await getPullRequestState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('closed');
+    });
+
+    it('draft pull request state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open', draft: true, merged: false });
+
+      const result = await getPullRequestState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('draft');
+    });
+
+    it('merged pull request state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open', draft: false, merged: true });
+
+      const result = await getPullRequestState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('merged');
+    });
+
+    it('open pull request state', async () => {
+      nock('https://api.github.com')
+        .get('/repos/manosim/notifications-test/issues/1')
+        .reply(200, { state: 'open', draft: false, merged: false });
+
+      const result = await getPullRequestState(
+        mockedSingleNotification,
+        mockAccounts.token,
+      );
+
+      expect(result).toBe('open');
+    });
+  });
+});