Repository: gitify-app/gitify
Pull request #1046 (2024-04-18 10:01:03)

refactor(api/errors): move error function and tests out
Piece by piece carving useNotifications back to something bite-sized


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 16171137..55ce5b45 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -38,6 +38,7 @@ body:
       label: Gitify Version
       description: What version of Gitify are you using?
       options:
+        - 5.4.0
         - 5.3.0
         - 5.2.0
         - 5.1.0
diff --git a/package.json b/package.json
index 5b5f3a30..5b6b2613 100644
--- a/package.json
+++ b/package.json
@@ -136,7 +136,7 @@
     "@types/react-transition-group": "4.4.10",
     "autoprefixer": "10.4.19",
     "css-loader": "7.1.1",
-    "electron": "30.0.0",
+    "electron": "30.0.1",
     "electron-builder": "24.13.3",
     "husky": "9.0.11",
     "jest": "29.7.0",
@@ -154,7 +154,7 @@
     "webpack-cli": "5.1.4",
     "webpack-merge": "5.10.0"
   },
-  "packageManager": "pnpm@9.0.2",
+  "packageManager": "pnpm@9.0.4",
   "lint-staged": {
     "*.{html,js,json,md,ts,tsx}": "biome format --write"
   }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bfedd70f..666a9514 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,7 +10,7 @@ importers:
     dependencies:
       '@electron/remote':
         specifier: 2.1.2
-        version: 2.1.2(electron@30.0.0)
+        version: 2.1.2(electron@30.0.1)
       '@primer/octicons-react':
         specifier: 19.9.0
         version: 19.9.0(react@18.2.0)
@@ -28,7 +28,7 @@ importers:
         version: 4.20.10
       menubar:
         specifier: 9.4.0
-        version: 9.4.0(electron@30.0.0)
+        version: 9.4.0(electron@30.0.1)
       nprogress:
         specifier: 0.2.0
         version: 0.2.0
@@ -91,8 +91,8 @@ importers:
         specifier: 7.1.1
         version: 7.1.1(webpack@5.91.0(webpack-cli@5.1.4))
       electron:
-        specifier: 30.0.0
-        version: 30.0.0
+        specifier: 30.0.1
+        version: 30.0.1
       electron-builder:
         specifier: 24.13.3
         version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
@@ -1367,8 +1367,8 @@ packages:
   electron-updater@6.1.8:
     resolution: {integrity: sha512-hhOTfaFAd6wRHAfUaBhnAOYc+ymSGCWJLtFkw4xJqOvtpHmIdNHnXDV9m1MHC+A6q08Abx4Ykgyz/R5DGKNAMQ==}
 
-  electron@30.0.0:
-    resolution: {integrity: sha512-GRwKphq/TUhSlb44OwSckXKl50f5OR/pm9MvF3rBLyqcxwfu7L11xejrZ0hDea1eKyCkzGd4B+cIqaQiDguPEA==}
+  electron@30.0.1:
+    resolution: {integrity: sha512-iwxkI/n2wBd29NH7TH0ZY8aWGzCoKpzJz+D10u7aGSJi1TV6d4MSM3rWyKvT/UkAHkTKOEgYfUyCa2vWQm8L0g==}
     engines: {node: '>= 12.20.55'}
     hasBin: true
 
@@ -3469,9 +3469,9 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@electron/remote@2.1.2(electron@30.0.0)':
+  '@electron/remote@2.1.2(electron@30.0.1)':
     dependencies:
-      electron: 30.0.0
+      electron: 30.0.1
 
   '@electron/universal@1.5.1':
     dependencies:
@@ -4708,7 +4708,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  electron@30.0.0:
+  electron@30.0.1:
     dependencies:
       '@electron/get': 2.0.3
       '@types/node': 20.12.7
@@ -5672,9 +5672,9 @@ snapshots:
       escape-string-regexp: 4.0.0
     optional: true
 
-  menubar@9.4.0(electron@30.0.0):
+  menubar@9.4.0(electron@30.0.1):
     dependencies:
-      electron: 30.0.0
+      electron: 30.0.1
       electron-positioner: 4.1.0
 
   merge-stream@2.0.0: {}
diff --git a/src/__mocks__/partial-mocks.ts b/src/__mocks__/partial-mocks.ts
new file mode 100644
index 00000000..e61e0d12
--- /dev/null
+++ b/src/__mocks__/partial-mocks.ts
@@ -0,0 +1,22 @@
+import type { Notification, Subject, User } from '../typesGithub';
+
+export function partialMockNotification(
+  subject: Partial<Subject>,
+): Notification {
+  const mockNotification: Partial<Notification> = {
+    subject: subject as Subject,
+  };
+
+  return mockNotification as Notification;
+}
+
+export function partialMockUser(login: string): User {
+  const mockUser: Partial<User> = {
+    login: login,
+    html_url: `https://github.com/${login}`,
+    avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
+    type: 'User',
+  };
+
+  return mockUser as User;
+}
diff --git a/src/components/NotificationRow.tsx b/src/components/NotificationRow.tsx
index ed5b3f54..517b5d7f 100644
--- a/src/components/NotificationRow.tsx
+++ b/src/components/NotificationRow.tsx
@@ -16,11 +16,11 @@ import {
 import { AppContext } from '../context/App';
 import type { Notification } from '../typesGithub';
 import { openExternalLink } from '../utils/comms';
+import { formatForDisplay, openInBrowser } from '../utils/helpers';
 import {
   getNotificationTypeIcon,
   getNotificationTypeIconColor,
-} from '../utils/github-api';
-import { formatForDisplay, openInBrowser } from '../utils/helpers';
+} from '../utils/icons';
 import { formatReason } from '../utils/reason';
 
 interface IProps {
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 6489147f..112b6db1 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,4 +1,4 @@
-import axios, { AxiosError, type AxiosPromise } from 'axios';
+import axios, { type AxiosError, type AxiosPromise } from 'axios';
 import { useCallback, useState } from 'react';
 
 import type {
@@ -9,7 +9,8 @@ import type {
 } from '../types';
 import type { GithubRESTError, Notification } from '../typesGithub';
 import { apiRequestAuth } from '../utils/api-requests';
-import Constants, { Errors } from '../utils/constants';
+import { determineFailureType } from '../utils/api/errors';
+import Constants from '../utils/constants';
 import {
   generateGitHubAPIUrl,
   getEnterpriseAccountToken,
@@ -403,37 +404,3 @@ export const useNotifications = (): NotificationsState => {
     markRepoNotificationsDone,
   };
 };
-
-function determineFailureType(err: AxiosError<GithubRESTError>): GitifyError {
-  const code = err.code;
-
-  if (code === AxiosError.ERR_NETWORK) {
-    return Errors.NETWORK;
-  }
-
-  if (code !== AxiosError.ERR_BAD_REQUEST) {
-    return Errors.UNKNOWN;
-  }
-
-  const status = err.response.status;
-  const message = err.response.data.message;
-
-  if (status === 401) {
-    return Errors.BAD_CREDENTIALS;
-  }
-
-  if (status === 403) {
-    if (message.includes("Missing the 'notifications' scope")) {
-      return Errors.MISSING_SCOPES;
-    }
-
-    if (
-      message.includes('API rate limit exceeded') ||
-      message.includes('You have exceeded a secondary rate limit')
-    ) {
-      return Errors.RATE_LIMITED;
-    }
-  }
-
-  return Errors.UNKNOWN;
-}
diff --git a/src/types.ts b/src/types.ts
index 1b318fb8..fb4f00fb 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -93,3 +93,10 @@ export interface FormattedReason {
   title: string;
   description: string;
 }
+
+export enum IconColor {
+  GREEN = 'text-green-500',
+  RED = 'text-red-500',
+  PURPLE = 'text-purple-500',
+  GRAY = 'text-gray-500 dark:text-gray-300',
+}
diff --git a/src/utils/api/errors.ts b/src/utils/api/errors.ts
new file mode 100644
index 00000000..2c69b2a5
--- /dev/null
+++ b/src/utils/api/errors.ts
@@ -0,0 +1,40 @@
+import { AxiosError } from 'axios';
+import type { GitifyError } from '../../types';
+import type { GithubRESTError } from '../../typesGithub';
+import { Errors } from '../constants';
+
+export function determineFailureType(
+  err: AxiosError<GithubRESTError>,
+): GitifyError {
+  const code = err.code;
+
+  if (code === AxiosError.ERR_NETWORK) {
+    return Errors.NETWORK;
+  }
+
+  if (code !== AxiosError.ERR_BAD_REQUEST) {
+    return Errors.UNKNOWN;
+  }
+
+  const status = err.response.status;
+  const message = err.response.data.message;
+
+  if (status === 401) {
+    return Errors.BAD_CREDENTIALS;
+  }
+
+  if (status === 403) {
+    if (message.includes("Missing the 'notifications' scope")) {
+      return Errors.MISSING_SCOPES;
+    }
+
+    if (
+      message.includes('API rate limit exceeded') ||
+      message.includes('You have exceeded a secondary rate limit')
+    ) {
+      return Errors.RATE_LIMITED;
+    }
+  }
+
+  return Errors.UNKNOWN;
+}
diff --git a/src/utils/github-api.ts b/src/utils/icons.ts
similarity index 93%
rename from src/utils/github-api.ts
rename to src/utils/icons.ts
index f392fdfe..a7cef5e0 100644
--- a/src/utils/github-api.ts
+++ b/src/utils/icons.ts
@@ -24,6 +24,7 @@ import {
   XIcon,
 } from '@primer/octicons-react';
 import type { FC } from 'react';
+import { IconColor } from '../types';
 import type { Subject } from '../typesGithub';
 
 export function getNotificationTypeIcon(subject: Subject): FC<OcticonProps> {
@@ -92,21 +93,21 @@ export function getNotificationTypeIcon(subject: Subject): FC<OcticonProps> {
   }
 }
 
-export function getNotificationTypeIconColor(subject: Subject): string {
+export function getNotificationTypeIconColor(subject: Subject): IconColor {
   switch (subject.state) {
     case 'open':
     case 'reopened':
     case 'ANSWERED':
     case 'success':
-      return 'text-green-500';
+      return IconColor.GREEN;
     case 'closed':
     case 'failure':
-      return 'text-red-500';
+      return IconColor.RED;
     case 'completed':
     case 'RESOLVED':
     case 'merged':
-      return 'text-purple-500';
+      return IconColor.PURPLE;
     default:
-      return 'text-gray-500 dark:text-gray-300';
+      return IconColor.GRAY;
   }
 }
diff --git a/src/utils/subject.ts b/src/utils/subject.ts
index 1ddba3d2..2eda8469 100644
--- a/src/utils/subject.ts
+++ b/src/utils/subject.ts
@@ -88,10 +88,16 @@ function getCheckSuiteStatus(statusDisplayName: string): CheckSuiteStatus {
 function getGitifySubjectForCheckSuite(
   notification: Notification,
 ): GitifySubject {
-  return {
-    state: getCheckSuiteAttributes(notification)?.status,
-    user: null,
-  };
+  const state = getCheckSuiteAttributes(notification)?.status;
+
+  if (state) {
+    return {
+      state: state,
+      user: null,
+    };
+  }
+
+  return null;
 }
 
 async function getGitifySubjectForCommit(
@@ -238,10 +244,16 @@ async function getGitifySubjectForRelease(
 function getGitifySubjectForWorkflowRun(
   notification: Notification,
 ): GitifySubject {
-  return {
-    state: getWorkflowRunAttributes(notification)?.status,
-    user: null,
-  };
+  const state = getWorkflowRunAttributes(notification)?.status;
+
+  if (state) {
+    return {
+      state: state,
+      user: null,
+    };
+  }
+
+  return null;
 }
 
 /**


Test patch:
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
index f4cd786e..6eb889fc 100644
--- a/src/hooks/useNotifications.test.ts
+++ b/src/hooks/useNotifications.test.ts
@@ -49,270 +49,48 @@ describe('hooks/useNotifications.ts', () => {
         expect(result.current.notifications[1].hostname).toBe('github.com');
       });
 
-      describe('should fetch notifications with failures - github.com & enterprise', () => {
-        it('network error', async () => {
-          const code = AxiosError.ERR_NETWORK;
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .replyWithError({
-              code: code,
-            });
-
-          nock('https://github.gitify.io/api/v3/')
-            .get('/notifications?participating=false')
-            .replyWithError({
-              code: code,
-            });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(mockAccounts, mockSettings);
-          });
-
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.NETWORK);
-          });
-        });
-
-        describe('bad request errors', () => {
-          it('bad credentials', async () => {
-            const code = AxiosError.ERR_BAD_REQUEST;
-            const status = 401;
-            const message = 'Bad credentials';
-
-            nock('https://api.github.com/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            nock('https://github.gitify.io/api/v3/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            const { result } = renderHook(() => useNotifications());
-
-            act(() => {
-              result.current.fetchNotifications(mockAccounts, mockSettings);
-            });
-
-            await waitFor(() => {
-              expect(result.current.requestFailed).toBe(true);
-              expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS);
-            });
-          });
-
-          it('missing scopes', async () => {
-            const code = AxiosError.ERR_BAD_REQUEST;
-            const status = 403;
-            const message = "Missing the 'notifications' scope";
-
-            nock('https://api.github.com/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            nock('https://github.gitify.io/api/v3/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            const { result } = renderHook(() => useNotifications());
+      it('should fetch notifications with failures - github.com & enterprise', async () => {
+        const code = AxiosError.ERR_BAD_REQUEST;
+        const status = 400;
+        const message = 'Oops! Something went wrong.';
 
-            act(() => {
-              result.current.fetchNotifications(mockAccounts, mockSettings);
-            });
-
-            await waitFor(() => {
-              expect(result.current.requestFailed).toBe(true);
-              expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
-            });
-          });
-
-          it('rate limited - primary', async () => {
-            const code = AxiosError.ERR_BAD_REQUEST;
-            const status = 403;
-            const message = 'API rate limit exceeded';
-
-            nock('https://api.github.com/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            nock('https://github.gitify.io/api/v3/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            const { result } = renderHook(() => useNotifications());
-
-            act(() => {
-              result.current.fetchNotifications(mockAccounts, mockSettings);
-            });
-
-            await waitFor(() => {
-              expect(result.current.requestFailed).toBe(true);
-              expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
-            });
+        nock('https://api.github.com/')
+          .get('/notifications?participating=false')
+          .replyWithError({
+            code,
+            response: {
+              status,
+              data: {
+                message,
+              },
+            },
           });
 
-          it('rate limited - secondary', async () => {
-            const code = AxiosError.ERR_BAD_REQUEST;
-            const status = 403;
-            const message = 'You have exceeded a secondary rate limit';
-
-            nock('https://api.github.com/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            nock('https://github.gitify.io/api/v3/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            const { result } = renderHook(() => useNotifications());
-
-            act(() => {
-              result.current.fetchNotifications(mockAccounts, mockSettings);
-            });
-
-            await waitFor(() => {
-              expect(result.current.requestFailed).toBe(true);
-              expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
-            });
+        nock('https://github.gitify.io/api/v3/')
+          .get('/notifications?participating=false')
+          .replyWithError({
+            code,
+            response: {
+              status,
+              data: {
+                message,
+              },
+            },
           });
 
-          it('unhandled bad request error', async () => {
-            const code = AxiosError.ERR_BAD_REQUEST;
-            const status = 400;
-            const message = 'Oops! Something went wrong.';
-
-            nock('https://api.github.com/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            nock('https://github.gitify.io/api/v3/')
-              .get('/notifications?participating=false')
-              .replyWithError({
-                code,
-                response: {
-                  status,
-                  data: {
-                    message,
-                  },
-                },
-              });
-
-            const { result } = renderHook(() => useNotifications());
-
-            act(() => {
-              result.current.fetchNotifications(mockAccounts, mockSettings);
-            });
-
-            expect(result.current.isFetching).toBe(true);
-
-            await waitFor(() => {
-              expect(result.current.isFetching).toBe(false);
-            });
+        const { result } = renderHook(() => useNotifications());
 
-            expect(result.current.requestFailed).toBe(true);
-          });
+        act(() => {
+          result.current.fetchNotifications(mockAccounts, mockSettings);
         });
 
-        it('unknown error', async () => {
-          const code = 'anything';
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .replyWithError({
-              code: code,
-            });
-
-          nock('https://github.gitify.io/api/v3/')
-            .get('/notifications?participating=false')
-            .replyWithError({
-              code: code,
-            });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(mockAccounts, mockSettings);
-          });
+        expect(result.current.isFetching).toBe(true);
 
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.UNKNOWN);
-          });
+        await waitFor(() => {
+          expect(result.current.isFetching).toBe(false);
         });
+
+        expect(result.current.requestFailed).toBe(true);
       });
     });
 
@@ -355,7 +133,15 @@ describe('hooks/useNotifications.ts', () => {
 
         nock('https://github.gitify.io/api/v3/')
           .get('/notifications?participating=false')
-          .reply(400, { message: 'Oops! Something went wrong.' });
+          .replyWithError({
+            code: AxiosError.ERR_BAD_REQUEST,
+            response: {
+              status: 400,
+              data: {
+                message: 'Oops! Something went wrong.',
+              },
+            },
+          });
 
         const { result } = renderHook(() => useNotifications());
 
@@ -399,125 +185,33 @@ describe('hooks/useNotifications.ts', () => {
         expect(result.current.notifications[0].notifications.length).toBe(2);
       });
 
-      describe('should fetch notifications with failures - github.com only', () => {
-        it('bad credentials', async () => {
-          const status = 401;
-          const message = 'Bad credentials';
-          const accounts: AuthState = {
-            ...mockAccounts,
-            enterpriseAccounts: [],
-          };
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .reply(status, { message });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(accounts, mockSettings);
-          });
-
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS);
-          });
-        });
-
-        it('missing scopes', async () => {
-          const status = 403;
-          const message = "Missing the 'notifications' scope";
-          const accounts: AuthState = {
-            ...mockAccounts,
-            enterpriseAccounts: [],
-          };
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .reply(status, { message });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(accounts, mockSettings);
-          });
-
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
-          });
-        });
-
-        it('rate limited - primary', async () => {
-          const status = 403;
-          const message = 'API rate limit exceeded';
-          const accounts: AuthState = {
-            ...mockAccounts,
-            enterpriseAccounts: [],
-          };
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .reply(status, { message });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(accounts, mockSettings);
-          });
+      it('should fetch notifications with failures - github.com only', async () => {
+        const accounts: AuthState = {
+          ...mockAccounts,
+          enterpriseAccounts: [],
+        };
 
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
+        nock('https://api.github.com/')
+          .get('/notifications?participating=false')
+          .replyWithError({
+            code: AxiosError.ERR_BAD_REQUEST,
+            response: {
+              status: 400,
+              data: {
+                message: 'Oops! Something went wrong.',
+              },
+            },
           });
-        });
-
-        it('rate limited - secondary', async () => {
-          const status = 403;
-          const message = 'You have exceeded a secondary rate limit';
-          const accounts: AuthState = {
-            ...mockAccounts,
-            enterpriseAccounts: [],
-          };
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .reply(status, { message });
-
-          const { result } = renderHook(() => useNotifications());
 
-          act(() => {
-            result.current.fetchNotifications(accounts, mockSettings);
-          });
+        const { result } = renderHook(() => useNotifications());
 
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
-          });
+        act(() => {
+          result.current.fetchNotifications(accounts, mockSettings);
         });
 
-        it('default error', async () => {
-          const status = 400;
-          const message = 'Oops! Something went wrong.';
-          const accounts: AuthState = {
-            ...mockAccounts,
-            enterpriseAccounts: [],
-          };
-
-          nock('https://api.github.com/')
-            .get('/notifications?participating=false')
-            .reply(status, { message });
-
-          const { result } = renderHook(() => useNotifications());
-
-          act(() => {
-            result.current.fetchNotifications(accounts, mockSettings);
-          });
-
-          await waitFor(() => {
-            expect(result.current.requestFailed).toBe(true);
-            expect(result.current.errorDetails).toBe(Errors.UNKNOWN);
-          });
+        await waitFor(() => {
+          expect(result.current.requestFailed).toBe(true);
+          expect(result.current.errorDetails).toBe(Errors.UNKNOWN);
         });
       });
     });
diff --git a/src/utils/__snapshots__/github-api.test.ts.snap b/src/utils/__snapshots__/github-api.test.ts.snap
deleted file mode 100644
index 2c5335a0..00000000
--- a/src/utils/__snapshots__/github-api.test.ts.snap
+++ /dev/null
@@ -1,31 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for check suite 1`] = `"text-gray-500 dark:text-gray-300"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for check suite 2`] = `"text-red-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for check suite 3`] = `"text-gray-500 dark:text-gray-300"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for check suite 4`] = `"text-green-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for check suite 5`] = `"text-gray-500 dark:text-gray-300"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 1`] = `"text-green-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 2`] = `"text-red-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 3`] = `"text-purple-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 4`] = `"text-gray-500 dark:text-gray-300"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 5`] = `"text-purple-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 6`] = `"text-gray-500 dark:text-gray-300"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 7`] = `"text-green-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 8`] = `"text-green-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 9`] = `"text-purple-500"`;
-
-exports[`utils/github-api.ts getNotificationTypeIconColor should format the notification color for state 10`] = `"text-gray-500 dark:text-gray-300"`;
diff --git a/src/utils/__snapshots__/icons.test.ts.snap b/src/utils/__snapshots__/icons.test.ts.snap
new file mode 100644
index 00000000..40d0bfd9
--- /dev/null
+++ b/src/utils/__snapshots__/icons.test.ts.snap
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for check suite 1`] = `"text-gray-500 dark:text-gray-300"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for check suite 2`] = `"text-red-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for check suite 3`] = `"text-gray-500 dark:text-gray-300"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for check suite 4`] = `"text-green-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for check suite 5`] = `"text-gray-500 dark:text-gray-300"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 1`] = `"text-green-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 2`] = `"text-red-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 3`] = `"text-purple-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 4`] = `"text-gray-500 dark:text-gray-300"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 5`] = `"text-purple-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 6`] = `"text-gray-500 dark:text-gray-300"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 7`] = `"text-green-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 8`] = `"text-green-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 9`] = `"text-purple-500"`;
+
+exports[`utils/icons.ts getNotificationTypeIconColor should format the notification color for state 10`] = `"text-gray-500 dark:text-gray-300"`;
diff --git a/src/utils/api/errors.test.ts b/src/utils/api/errors.test.ts
new file mode 100644
index 00000000..dd7d9a32
--- /dev/null
+++ b/src/utils/api/errors.test.ts
@@ -0,0 +1,123 @@
+import { AxiosError, type AxiosResponse } from 'axios';
+import type { GithubRESTError } from '../../typesGithub';
+import { Errors } from '../constants';
+import { determineFailureType } from './errors';
+
+describe('utils/api/errors.ts', () => {
+  it('network error', async () => {
+    const mockError: Partial<AxiosError<GithubRESTError>> = {
+      code: AxiosError.ERR_NETWORK,
+    };
+
+    const result = determineFailureType(
+      mockError as AxiosError<GithubRESTError>,
+    );
+
+    expect(result).toBe(Errors.NETWORK);
+  });
+
+  describe('bad request errors', () => {
+    it('bad credentials', async () => {
+      const mockError: Partial<AxiosError<GithubRESTError>> = {
+        code: AxiosError.ERR_BAD_REQUEST,
+        status: 401,
+        response: createMockResponse(401, 'Bad credentials'),
+      };
+
+      const result = determineFailureType(
+        mockError as AxiosError<GithubRESTError>,
+      );
+
+      expect(result).toBe(Errors.BAD_CREDENTIALS);
+    });
+
+    it('missing scopes', async () => {
+      const mockError: Partial<AxiosError<GithubRESTError>> = {
+        code: AxiosError.ERR_BAD_REQUEST,
+        status: 403,
+        response: createMockResponse(403, "Missing the 'notifications' scope"),
+      };
+
+      const result = determineFailureType(
+        mockError as AxiosError<GithubRESTError>,
+      );
+
+      expect(result).toBe(Errors.MISSING_SCOPES);
+    });
+
+    it('rate limited - primary', async () => {
+      const mockError: Partial<AxiosError<GithubRESTError>> = {
+        code: AxiosError.ERR_BAD_REQUEST,
+        status: 403,
+        response: createMockResponse(403, 'API rate limit exceeded'),
+      };
+
+      const result = determineFailureType(
+        mockError as AxiosError<GithubRESTError>,
+      );
+
+      expect(result).toBe(Errors.RATE_LIMITED);
+    });
+
+    it('rate limited - secondary', async () => {
+      const mockError: Partial<AxiosError<GithubRESTError>> = {
+        code: AxiosError.ERR_BAD_REQUEST,
+        status: 403,
+        response: createMockResponse(
+          403,
+          'You have exceeded a secondary rate limit',
+        ),
+      };
+
+      const result = determineFailureType(
+        mockError as AxiosError<GithubRESTError>,
+      );
+
+      expect(result).toBe(Errors.RATE_LIMITED);
+    });
+
+    it('unhandled bad request error', async () => {
+      const mockError: Partial<AxiosError<GithubRESTError>> = {
+        code: AxiosError.ERR_BAD_REQUEST,
+        status: 400,
+        response: createMockResponse(403, 'Oops! Something went wrong.'),
+      };
+
+      const result = determineFailureType(
+        mockError as AxiosError<GithubRESTError>,
+      );
+
+      expect(result).toBe(Errors.UNKNOWN);
+    });
+  });
+
+  it('unknown error', async () => {
+    const mockError: Partial<AxiosError<GithubRESTError>> = {
+      code: 'anything',
+    };
+
+    const result = determineFailureType(
+      mockError as AxiosError<GithubRESTError>,
+    );
+
+    expect(result).toBe(Errors.UNKNOWN);
+  });
+});
+
+function createMockResponse(
+  status: number,
+  message: string,
+): AxiosResponse<GithubRESTError> {
+  return {
+    data: {
+      message,
+      documentation_url: 'https://some-url.com',
+    },
+    status,
+    statusText: 'Some status text',
+    headers: {},
+    config: {
+      headers: undefined,
+    },
+  };
+}
diff --git a/src/utils/github-api.test.ts b/src/utils/icons.test.ts
similarity index 98%
rename from src/utils/github-api.test.ts
rename to src/utils/icons.test.ts
index 70ebd6b3..f80e600a 100644
--- a/src/utils/github-api.test.ts
+++ b/src/utils/icons.test.ts
@@ -1,10 +1,7 @@
 import type { StateType, Subject, SubjectType } from '../typesGithub';
-import {
-  getNotificationTypeIcon,
-  getNotificationTypeIconColor,
-} from './github-api';
+import { getNotificationTypeIcon, getNotificationTypeIconColor } from './icons';
 
-describe('utils/github-api.ts', () => {
+describe('utils/icons.ts', () => {
   describe('getNotificationTypeIcon - should get the notification type icon', () => {
     expect(
       getNotificationTypeIcon(
diff --git a/src/utils/subject.test.ts b/src/utils/subject.test.ts
index 6ce48604..8a26b8b7 100644
--- a/src/utils/subject.test.ts
+++ b/src/utils/subject.test.ts
@@ -3,15 +3,32 @@ import nock from 'nock';
 
 import { mockAccounts } from '../__mocks__/mock-state';
 import {
-  mockedNotificationUser,
-  mockedSingleNotification,
-} from '../__mocks__/mockedData';
-import type { SubjectType } from '../typesGithub';
+  partialMockNotification,
+  partialMockUser,
+} from '../__mocks__/partial-mocks';
+import type {
+  Discussion,
+  DiscussionAuthor,
+  DiscussionStateType,
+  Notification,
+  Repository,
+  ViewerSubscription,
+} from '../typesGithub';
 import {
   getCheckSuiteAttributes,
   getGitifySubjectDetails,
   getWorkflowRunAttributes,
 } from './subject';
+
+const mockAuthor = partialMockUser('some-author');
+const mockCommenter = partialMockUser('some-commenter');
+const mockDiscussionAuthor: DiscussionAuthor = {
+  login: 'discussion-author',
+  url: 'https://github.com/discussion-author',
+  avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
+  type: 'User',
+};
+
 describe('utils/subject.ts', () => {
   beforeEach(() => {
     // axios will default to using the XHR adapter which can't be intercepted
@@ -19,218 +36,209 @@ describe('utils/subject.ts', () => {
     axios.defaults.adapter = 'http';
   });
 
-  describe('getCheckSuiteState', () => {
-    it('cancelled check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+  describe('getGitifySubjectDetails', () => {
+    describe('CheckSuites - GitHub Actions', () => {
+      it('cancelled check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run cancelled for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBeNull();
-      expect(result.status).toBe('cancelled');
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toEqual({
+          state: 'cancelled',
+          user: null,
+        });
+      });
 
-    it('failed check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('failed check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run failed for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBeNull();
-      expect(result.status).toBe('failure');
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toEqual({
+          state: 'failure',
+          user: null,
+        });
+      });
 
-    it('multiple attempts failed check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('multiple attempts failed check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run, Attempt #3 failed for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBe(3);
-      expect(result.status).toBe('failure');
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toEqual({
+          state: 'failure',
+          user: null,
+        });
+      });
 
-    it('skipped check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('skipped check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run skipped for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBeNull();
-      expect(result.status).toBe('skipped');
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toEqual({
+          state: 'skipped',
+          user: null,
+        });
+      });
 
-    it('successful check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('successful check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run succeeded for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBeNull();
-      expect(result.status).toBe('success');
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toEqual({
+          state: 'success',
+          user: null,
+        });
+      });
 
-    it('unknown check suite state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('unknown check suite state', async () => {
+        const mockNotification = partialMockNotification({
           title: 'Demo workflow run unknown-status for main branch',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result.workflowName).toBe('Demo');
-      expect(result.attemptNumber).toBeNull();
-      expect(result.status).toBeNull();
-      expect(result.branchName).toBe('main');
-    });
+        expect(result).toBeNull();
+      });
 
-    it('unhandled check suite title', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
+      it('unhandled check suite title', async () => {
+        const mockNotification = partialMockNotification({
           title: 'A title that is not in the structure we expect',
-        },
-      };
+          type: 'CheckSuite',
+        });
 
-      const result = getCheckSuiteAttributes(mockNotification);
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
 
-      expect(result).toBeNull();
+        expect(result).toBeNull();
+      });
     });
-  });
-
-  describe('getGitifySubjectDetails', () => {
     describe('Commits', () => {
       it('get commit commenter', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            title: 'This is a commit with comments',
-            url: 'https://api.github.com/repos/manosim/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
-            latest_comment_url:
-              'https://api.github.com/repos/manosim/notifications-test/comments/141012658',
-            type: 'Commit' as SubjectType,
-          },
-        };
+        const mockNotification = partialMockNotification({
+          title: 'This is a commit with comments',
+          type: 'Commit',
+          url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
+          latest_comment_url:
+            'https://api.github.com/repos/gitify-app/notifications-test/comments/141012658',
+        });
 
         nock('https://api.github.com')
           .get(
-            '/repos/manosim/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
+            '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
           )
-          .reply(200, { author: { login: 'some-author' } });
+          .reply(200, { author: mockAuthor });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/comments/141012658')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/comments/141012658')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBeNull();
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: null,
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('get commit without commenter', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            title: 'This is a commit with comments',
-            url: 'https://api.github.com/repos/manosim/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
-            latest_comment_url: null,
-            type: 'Commit' as SubjectType,
-          },
-        };
+        const mockNotification = partialMockNotification({
+          title: 'This is a commit with comments',
+          type: 'Commit',
+          url: 'https://api.github.com/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
+          latest_comment_url: null,
+        });
 
         nock('https://api.github.com')
           .get(
-            '/repos/manosim/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
+            '/repos/gitify-app/notifications-test/commits/d2a86d80e3d24ea9510d5de6c147e53c30f313a8',
           )
-          .reply(200, { author: { login: 'some-author' } });
+          .reply(200, { author: mockAuthor });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBeNull();
-        expect(result.user).toEqual({ login: 'some-author' });
+        expect(result).toEqual({
+          state: null,
+          user: {
+            login: mockAuthor.login,
+            html_url: mockAuthor.html_url,
+            avatar_url: mockAuthor.avatar_url,
+            type: mockAuthor.type,
+          },
+        });
       });
     });
 
     describe('Discussions', () => {
-      it('answered discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is an answered discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
+      const partialRepository: Partial<Repository> = {
+        full_name: 'gitify-app/notifications-test',
+      };
 
+      const mockNotification = partialMockNotification({
+        title: 'This is a mocked discussion',
+        type: 'Discussion',
+      });
+      mockNotification.updated_at = '2024-01-01T00:00:00Z';
+      mockNotification.repository = {
+        ...(partialRepository as Repository),
+      };
+
+      it('answered discussion state', async () => {
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is an answered discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: null,
-                    isAnswered: true,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', null, true)],
               },
             },
           });
@@ -240,48 +248,24 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('ANSWERED');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'ANSWERED',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('duplicate discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is a duplicate discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is a duplicate discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: 'DUPLICATE',
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', 'DUPLICATE', false)],
               },
             },
           });
@@ -291,48 +275,24 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('DUPLICATE');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'DUPLICATE',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('open discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is an open discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is an open discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: null,
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', null, false)],
               },
             },
           });
@@ -342,48 +302,24 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('OPEN');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'OPEN',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('outdated discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is an outdated discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is an outdated discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: 'OUTDATED',
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', 'OUTDATED', false)],
               },
             },
           });
@@ -393,48 +329,24 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('OUTDATED');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'OUTDATED',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('reopened discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is a reopened discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is a reopened discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: 'REOPENED',
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', 'REOPENED', false)],
               },
             },
           });
@@ -444,48 +356,24 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('REOPENED');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'REOPENED',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('resolved discussion state', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is a resolved discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
-                nodes: [
-                  {
-                    title: 'This is a resolved discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: 'RESOLVED',
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                ],
+                nodes: [mockDiscussionNode('SUBSCRIBED', 'RESOLVED', true)],
               },
             },
           });
@@ -495,63 +383,26 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('RESOLVED');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'RESOLVED',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
 
       it('filtered response by subscribed', async () => {
-        const mockNotification = {
-          ...mockedSingleNotification,
-          subject: {
-            ...mockedSingleNotification.subject,
-            title: 'This is a discussion',
-            type: 'Discussion' as SubjectType,
-          },
-        };
-
         nock('https://api.github.com')
           .post('/graphql')
           .reply(200, {
             data: {
               search: {
                 nodes: [
-                  {
-                    title: 'This is a discussion',
-                    viewerSubscription: 'SUBSCRIBED',
-                    stateReason: null,
-                    isAnswered: false,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
-                  {
-                    title: 'This is a discussion',
-                    viewerSubscription: 'IGNORED',
-                    stateReason: null,
-                    isAnswered: true,
-                    author: {
-                      login: 'discussion-creator',
-                      url: 'https://github.com/discussion-creator',
-                      avatar_url:
-                        'https://avatars.githubusercontent.com/u/583231?v=4',
-                      type: 'User',
-                    },
-                    comments: {
-                      nodes: [], //TODO - Update this to have real data
-                    },
-                  },
+                  mockDiscussionNode('SUBSCRIBED', null, false),
+                  mockDiscussionNode('IGNORED', null, true),
                 ],
               },
             },
@@ -562,344 +413,607 @@ describe('utils/subject.ts', () => {
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('OPEN');
-        expect(result.user).toEqual({
-          login: 'discussion-creator',
-          html_url: 'https://github.com/discussion-creator',
-          avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-          type: 'User',
+        expect(result).toEqual({
+          state: 'OPEN',
+          user: {
+            login: mockDiscussionAuthor.login,
+            html_url: mockDiscussionAuthor.url,
+            avatar_url: mockDiscussionAuthor.avatar_url,
+            type: mockDiscussionAuthor.type,
+          },
         });
       });
     });
 
     describe('Issues', () => {
+      let mockNotification: Notification;
+      beforeEach(() => {
+        mockNotification = partialMockNotification({
+          title: 'This is a mocked issue',
+          type: 'Issue',
+          url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1',
+          latest_comment_url:
+            'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448',
+        });
+      });
+
       it('open issue state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
-          .reply(200, { state: 'open', user: { login: 'some-user' } });
+          .get('/repos/gitify-app/notifications-test/issues/1')
+          .reply(200, { state: 'open', user: mockAuthor });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
-          mockedSingleNotification,
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('open');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'open',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('closed issue state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
-          .reply(200, { state: 'closed', user: { login: 'some-user' } });
+          .get('/repos/gitify-app/notifications-test/issues/1')
+          .reply(200, { state: 'closed', user: mockAuthor });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
-          mockedSingleNotification,
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('closed');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'closed',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('completed issue state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'closed',
             state_reason: 'completed',
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
-          mockedSingleNotification,
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('completed');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'completed',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('not_planned issue state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             state_reason: 'not_planned',
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
-          mockedSingleNotification,
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('not_planned');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'not_planned',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('reopened issue state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             state_reason: 'reopened',
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
-          mockedSingleNotification,
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('reopened');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'reopened',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('handle issues without latest_comment_url', async () => {
+        mockNotification.subject.latest_comment_url = null;
+
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             draft: false,
             merged: false,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         const result = await getGitifySubjectDetails(
-          {
-            ...mockedSingleNotification,
-            subject: {
-              ...mockedSingleNotification.subject,
-              latest_comment_url: null,
-            },
-          },
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('open');
-        expect(result.user).toEqual({ login: 'some-user' });
+        expect(result).toEqual({
+          state: 'open',
+          user: {
+            login: mockAuthor.login,
+            html_url: mockAuthor.html_url,
+            avatar_url: mockAuthor.avatar_url,
+            type: mockAuthor.type,
+          },
+        });
       });
     });
 
     describe('Pull Requests', () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
-          type: 'PullRequest' as SubjectType,
-        },
-      };
+      let mockNotification: Notification;
+
+      beforeEach(() => {
+        mockNotification = partialMockNotification({
+          title: 'This is a mocked pull request',
+          type: 'PullRequest',
+          url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1',
+          latest_comment_url:
+            'https://api.github.com/repos/gitify-app/notifications-test/issues/comments/302888448',
+        });
+      });
 
       it('closed pull request state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'closed',
             draft: false,
             merged: false,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('closed');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'closed',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('draft pull request state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             draft: true,
             merged: false,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('draft');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'draft',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('merged pull request state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             draft: false,
             merged: true,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('merged');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'merged',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('open pull request state', async () => {
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             draft: false,
             merged: false,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/comments/302888448')
-          .reply(200, { user: { login: 'some-commenter' } });
+          .get('/repos/gitify-app/notifications-test/issues/comments/302888448')
+          .reply(200, { user: mockCommenter });
 
         const result = await getGitifySubjectDetails(
           mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('open');
-        expect(result.user).toEqual({ login: 'some-commenter' });
+        expect(result).toEqual({
+          state: 'open',
+          user: {
+            login: mockCommenter.login,
+            html_url: mockCommenter.html_url,
+            avatar_url: mockCommenter.avatar_url,
+            type: mockCommenter.type,
+          },
+        });
       });
 
       it('handle pull request without latest_comment_url', async () => {
+        mockNotification.subject.latest_comment_url = null;
+
         nock('https://api.github.com')
-          .get('/repos/manosim/notifications-test/issues/1')
+          .get('/repos/gitify-app/notifications-test/issues/1')
           .reply(200, {
             state: 'open',
             draft: false,
             merged: false,
-            user: { login: 'some-user' },
+            user: mockAuthor,
           });
 
         const result = await getGitifySubjectDetails(
-          {
-            ...mockNotification,
-            subject: {
-              ...mockNotification.subject,
-              latest_comment_url: null,
-            },
+          mockNotification,
+          mockAccounts.token,
+        );
+
+        expect(result).toEqual({
+          state: 'open',
+          user: {
+            login: mockAuthor.login,
+            html_url: mockAuthor.html_url,
+            avatar_url: mockAuthor.avatar_url,
+            type: mockAuthor.type,
+          },
+        });
+      });
+    });
+
+    describe('Releases', () => {
+      it('release notification', async () => {
+        const mockNotification = partialMockNotification({
+          title: 'This is a mocked release',
+          type: 'Release',
+          url: 'https://api.github.com/repos/gitify-app/notifications-test/releases/1',
+          latest_comment_url:
+            'https://api.github.com/repos/gitify-app/notifications-test/releases/1',
+        });
+
+        nock('https://api.github.com')
+          .get('/repos/gitify-app/notifications-test/releases/1')
+          .reply(200, { author: mockAuthor });
+
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
+
+        expect(result).toEqual({
+          state: null,
+          user: {
+            login: mockAuthor.login,
+            html_url: mockAuthor.html_url,
+            avatar_url: mockAuthor.avatar_url,
+            type: mockAuthor.type,
           },
+        });
+      });
+    });
+
+    describe('WorkflowRuns - GitHub Actions', () => {
+      it('deploy review workflow run state', async () => {
+        const mockNotification = partialMockNotification({
+          title: 'some-user requested your review to deploy to an environment',
+          type: 'WorkflowRun',
+        });
+
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
+
+        expect(result).toEqual({
+          state: 'waiting',
+          user: null,
+        });
+      });
+
+      it('unknown workflow run state', async () => {
+        const mockNotification = partialMockNotification({
+          title:
+            'some-user requested your unknown-state to deploy to an environment',
+          type: 'WorkflowRun',
+        });
+
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
+
+        expect(result).toBeNull();
+      });
+
+      it('unhandled workflow run title', async () => {
+        const mockNotification = partialMockNotification({
+          title: 'unhandled workflow run structure',
+          type: 'WorkflowRun',
+        });
+
+        const result = await getGitifySubjectDetails(
+          mockNotification,
+          mockAccounts.token,
+        );
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('Default', () => {
+      it('unhandled subject details', async () => {
+        const mockNotification = partialMockNotification({
+          title:
+            'There is no special subject handling for this notification type',
+          type: 'RepositoryInvitation',
+        });
+
+        const result = await getGitifySubjectDetails(
+          mockNotification,
           mockAccounts.token,
         );
 
-        expect(result.state).toBe('open');
-        expect(result.user).toEqual({ login: 'some-user' });
+        expect(result).toBeNull();
       });
     });
   });
 
-  describe('getGitifySubjectForRelease', () => {
-    it('release notification', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
-          type: 'Release' as SubjectType,
-          url: 'https://api.github.com/repos/manosim/notifications-test/releases/1',
-          latest_comment_url:
-            'https://api.github.com/repos/manosim/notifications-test/releases/1',
-        },
-      };
+  describe('getCheckSuiteState', () => {
+    it('cancelled check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run cancelled for feature/foo branch',
+        type: 'CheckSuite',
+      });
+
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: null,
+        status: 'cancelled',
+        statusDisplayName: 'cancelled',
+        branchName: 'feature/foo',
+      });
+    });
+
+    it('failed check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run failed for main branch',
+        type: 'CheckSuite',
+      });
+
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: null,
+        status: 'failure',
+        statusDisplayName: 'failed',
+        branchName: 'main',
+      });
+    });
+
+    it('multiple attempts failed check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run, Attempt #3 failed for main branch',
+        type: 'CheckSuite',
+      });
+
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: 3,
+        status: 'failure',
+        statusDisplayName: 'failed',
+        branchName: 'main',
+      });
+    });
+
+    it('skipped check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run skipped for main branch',
+        type: 'CheckSuite',
+      });
 
-      nock('https://api.github.com')
-        .get('/repos/manosim/notifications-test/releases/1')
-        .reply(200, { author: mockedNotificationUser });
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: null,
+        status: 'skipped',
+        statusDisplayName: 'skipped',
+        branchName: 'main',
+      });
+    });
+
+    it('successful check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run succeeded for main branch',
+        type: 'CheckSuite',
+      });
 
-      const result = await getGitifySubjectDetails(
-        mockNotification,
-        mockAccounts.token,
-      );
+      const result = getCheckSuiteAttributes(mockNotification);
 
-      expect(result.user).toEqual({
-        login: 'octocat',
-        html_url: 'https://github.com/octocat',
-        avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
-        type: 'User',
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: null,
+        status: 'success',
+        statusDisplayName: 'succeeded',
+        branchName: 'main',
       });
     });
+
+    it('unknown check suite state', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'Demo workflow run unknown-status for main branch',
+        type: 'CheckSuite',
+      });
+
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toEqual({
+        workflowName: 'Demo',
+        attemptNumber: null,
+        status: null,
+        statusDisplayName: 'unknown-status',
+        branchName: 'main',
+      });
+    });
+
+    it('unhandled check suite title', async () => {
+      const mockNotification = partialMockNotification({
+        title: 'A title that is not in the structure we expect',
+        type: 'CheckSuite',
+      });
+
+      const result = getCheckSuiteAttributes(mockNotification);
+
+      expect(result).toBeNull();
+    });
   });
 
   describe('getWorkflowRunState', () => {
     it('deploy review workflow run state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
-          title: 'some-user requested your review to deploy to an environment',
-        },
-      };
+      const mockNotification = partialMockNotification({
+        title: 'some-user requested your review to deploy to an environment',
+        type: 'WorkflowRun',
+      });
 
       const result = getWorkflowRunAttributes(mockNotification);
 
-      expect(result.status).toBe('waiting');
-      expect(result.user).toBe('some-user');
+      expect(result).toEqual({
+        status: 'waiting',
+        statusDisplayName: 'review',
+        user: 'some-user',
+      });
     });
 
     it('unknown workflow run state', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
-          title:
-            'some-user requested your unknown-state to deploy to an environment',
-        },
-      };
+      const mockNotification = partialMockNotification({
+        title:
+          'some-user requested your unknown-state to deploy to an environment',
+        type: 'WorkflowRun',
+      });
 
       const result = getWorkflowRunAttributes(mockNotification);
 
-      expect(result.status).toBeNull();
-      expect(result.user).toBe('some-user');
+      expect(result).toEqual({
+        status: null,
+        statusDisplayName: 'unknown-state',
+        user: 'some-user',
+      });
     });
 
     it('unhandled workflow run title', async () => {
-      const mockNotification = {
-        ...mockedSingleNotification,
-        subject: {
-          ...mockedSingleNotification.subject,
-          title: 'unhandled workflow run structure',
-        },
-      };
+      const mockNotification = partialMockNotification({
+        title: 'unhandled workflow run structure',
+        type: 'WorkflowRun',
+      });
 
       const result = getWorkflowRunAttributes(mockNotification);
 
@@ -907,3 +1021,22 @@ describe('utils/subject.ts', () => {
     });
   });
 });
+
+function mockDiscussionNode(
+  subscription: ViewerSubscription,
+  state: DiscussionStateType,
+  isAnswered: boolean,
+): Discussion {
+  return {
+    title: 'This is a mocked discussion',
+    url: 'https://github.com/gitify-app/notifications-test/discussions/1',
+    viewerSubscription: subscription,
+    stateReason: state,
+    isAnswered: isAnswered,
+    author: mockDiscussionAuthor,
+    comments: {
+      //TODO - Update this to have real data
+      nodes: [],
+    },
+  };
+}