From 3ff8729295f0139928ff3001dc355111b19d87ba Mon Sep 17 00:00:00 2001
From: Jordan Wong <42422209+JorWo@users.noreply.github.com>
Date: Fri, 8 Mar 2024 12:38:23 -1000
Subject: [PATCH 1/2] Implement email stack trace (#9)

---
 ui/.eslintrc.json                             |   2 +-
 ui/src/app/about/page.tsx                     |   3 +-
 ui/src/components/layout/navbar/Navbar.tsx    |   2 +-
 ui/src/services/EmailService.ts               |  22 ++-
 ui/src/services/FetchService.ts               | 155 ++++++++++++----
 ui/tests/app/about/About.test.tsx             |   5 +-
 ui/tests/services/EmailService.test.ts        |  27 ++-
 ui/tests/services/GroupingsApiService.test.ts | 165 +++++++++++++-----
 8 files changed, 298 insertions(+), 83 deletions(-)

diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json
index c0ce4add..a0f304c1 100644
--- a/ui/.eslintrc.json
+++ b/ui/.eslintrc.json
@@ -27,7 +27,7 @@
       "eslint-plugin-tsdoc"
   ],
   "rules": {
-    "@stylistic/max-len": ["error", { "code": 120 }],
+    "@stylistic/max-len": ["warn", { "code": 120 }],
     "@stylistic/indent": ["error", 4],
     "@stylistic/eol-last": ["error", "always"],
     "@stylistic/quotes": ["error", "single", { "allowTemplateLiterals": true }],
diff --git a/ui/src/app/about/page.tsx b/ui/src/app/about/page.tsx
index 5d95aba3..48302376 100644
--- a/ui/src/app/about/page.tsx
+++ b/ui/src/app/about/page.tsx
@@ -60,8 +60,7 @@ const About = () => (
                     <h3 className="text-text-color text-lg pb-1">How do I request a new grouping?</h3>
                     <p className="pb-7">
                         <a className="text-link-color hover:underline hover:text-link-hover-color"
-                            href="https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/
-                                UH+Groupings+Request+Form"
+                            href="https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/UH+Groupings+Request+Form"
                             aria-label="A request form is available">A request form is available</a>.
                     </p>
 
diff --git a/ui/src/components/layout/navbar/Navbar.tsx b/ui/src/components/layout/navbar/Navbar.tsx
index 6c4b1504..a775626b 100644
--- a/ui/src/components/layout/navbar/Navbar.tsx
+++ b/ui/src/components/layout/navbar/Navbar.tsx
@@ -10,7 +10,7 @@ const Navbar = async () => {
     const currentUser = await getCurrentUser();
     
     return ( 
-        <nav className="border-b-[1px] pointer-events-auto">
+        <nav className="bg-white border-b-[1px] pointer-events-auto sticky top-0">
             <div className="container py-2">
                 <div className="flex justify-between">
                     <Link href="/" className="lg:inline hidden">
diff --git a/ui/src/services/EmailService.ts b/ui/src/services/EmailService.ts
index 9ac99b22..204ec896 100644
--- a/ui/src/services/EmailService.ts
+++ b/ui/src/services/EmailService.ts
@@ -22,8 +22,28 @@ export type EmailResult = {
     text: string
 }
 
+/**
+ * Sends feedback to Groupings API to send email.
+ * 
+ * @param feedback - the feedback
+ * 
+ * @returns The EmailResult
+ */
 export const sendFeedback = async (feedback: Feedback): Promise<EmailResult & ApiError> => {
     const currentUser = await getCurrentUser();
     const endpoint = `${baseUrl}/email/send/feedback`;
-    return postRequest(endpoint, currentUser.uid, feedback);
+    return postRequest<EmailResult>(endpoint, currentUser.uid, feedback);
+}
+
+/**
+ * Sends feedback to Groupings API to send stack trace email.
+ * 
+ * @param stackTrace - the stack trace
+ * 
+ * @returns The EmailResult
+ */
+export const sendStackTrace = async (stackTrace: string): Promise<EmailResult & ApiError> => {
+    const currentUser = await getCurrentUser();
+    const endpoint = `${baseUrl}/email/send/stack-trace`;
+    return postRequest<EmailResult>(endpoint, currentUser.uid, stackTrace, 'text/plain');
 }
diff --git a/ui/src/services/FetchService.ts b/ui/src/services/FetchService.ts
index fd2cfe93..4b2c73dd 100644
--- a/ui/src/services/FetchService.ts
+++ b/ui/src/services/FetchService.ts
@@ -2,10 +2,23 @@
 
 import { ApiError } from '../groupings/GroupingsApiResults';
 import { getCurrentUser } from '@/access/AuthenticationService';
+import { sendStackTrace } from './EmailService';
 
 const maxRetries = 3;
 const baseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string;
 
+enum HTTPMethod {
+    GET = 'GET',
+    POST = 'POST',
+    PUT = 'PUT',
+    DELETE = 'DELETE'
+}
+
+enum Status {
+    COMPLETED = 'COMPLETED',
+    IN_PROGRESS = 'IN_PROGRESS'
+}
+
 /**
  * Sleep/wait for the specified milliseconds.
  * 
@@ -24,10 +37,10 @@ const delay = async (ms = 5000) => new Promise((res) => setTimeout(res, ms));
  */
 const poll = async <T> (jobId: number): Promise<T & ApiError> => {
     const currentUser = await getCurrentUser();
-    return fetch(`${baseUrl}/jobs/${jobId}`, { headers: { 'current_user': currentUser.uid } })
-        .then(res => res.json())
+    return await fetch(`${baseUrl}/jobs/${jobId}`, { headers: { 'current_user': currentUser.uid } })
+        .then(res => handleFetch(res, HTTPMethod.GET))
         .then(async res => {
-            if (res.status === 'COMPLETED') {
+            if (res.status === Status.COMPLETED) {
                 return res.result;
             } 
             await delay();
@@ -49,7 +62,7 @@ export const getRequest = async <T> (
     currentUserKey: string = ''
 ): Promise<T & ApiError> => 
     await fetch(endpoint, { headers: { 'current_user': currentUserKey } })
-        .then(res => res.json())
+        .then(res => handleFetch(res, HTTPMethod.GET))
         .catch(err => err);
 
 /**
@@ -65,12 +78,17 @@ export const postRequest = async <T> (
     endpoint: string, 
     currentUserKey: string,
     body?: object | string | string[], 
+    contentType = 'application/json'
 ): Promise<T & ApiError> => 
     await fetch(endpoint, { 
-        method: 'POST', 
-        headers: { 'current_user': currentUserKey }, 
-        body: JSON.stringify(body)})
-        .then(res => res.json())
+        method: HTTPMethod.POST, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.POST))
         .catch(err => err);
 
 /**
@@ -85,10 +103,18 @@ export const postRequest = async <T> (
 export const postRequestAsync = async <T> (
     endpoint: string,
     currentUserKey: string,
-    body: string | string[]
+    body?: object | string | string[],
+    contentType = 'application/json'
 ): Promise<T & ApiError> => 
-    await fetch(endpoint, { method: 'POST', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) })
-        .then(res => res.json())
+    await fetch(endpoint, { 
+        method: HTTPMethod.POST, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.POST))
         .then(res => poll<T>(res))
         .catch(err => err);
 
@@ -104,16 +130,24 @@ export const postRequestAsync = async <T> (
 export const postRequestRetry = async <T> (
     endpoint: string, 
     currentUserKey: string,
-    body: string | string[],
+    body?: object | string | string[],
+    contentType = 'application/json',
     retries: number = maxRetries
 ): Promise<T & ApiError> =>
-    await fetch(endpoint, { method: 'POST', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) })
+    await fetch(endpoint, {
+        method: HTTPMethod.POST, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
         .then(async res => {
             if (res.status === 500 && retries > 0) {
                 await delay(2000 * Math.log(maxRetries / retries));
-                return postRequestRetry(endpoint, currentUserKey, body, retries - 1);
+                return postRequestRetry(endpoint, currentUserKey, body, contentType, retries - 1);
             }
-            return res.json();
+            return handleFetch(res, HTTPMethod.POST);
         })
         .catch(err => err);
     
@@ -130,13 +164,18 @@ export const postRequestRetry = async <T> (
 export const putRequest = async <T> (
     endpoint: string,
     currentUserKey: string,
-    body?: object | string | string[]
+    body?: object | string | string[],
+    contentType = 'application/json'
 ): Promise<T & ApiError> => 
     await fetch(endpoint, { 
-        method: 'PUT', 
-        headers: { 'current_user': currentUserKey }, 
-        body: JSON.stringify(body) })
-        .then(res => res.json())
+        method: HTTPMethod.PUT, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.PUT))
         .catch(err => err);
 
 /**
@@ -151,10 +190,18 @@ export const putRequest = async <T> (
 export const putRequestAsync = async <T> (
     endpoint: string,
     currentUserKey: string,
-    body: string | string[]
+    body?: object | string | string[],
+    contentType = 'application/json'
 ): Promise<T & ApiError> => 
-    await fetch(endpoint, { method: 'PUT', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) })
-        .then(res => res.json())
+    await fetch(endpoint, { 
+        method: HTTPMethod.PUT, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.PUT))
         .then(res => poll<T>(res))
         .catch(err => err);
 
@@ -170,11 +217,18 @@ export const putRequestAsync = async <T> (
 export const deleteRequest = async <T> (
     endpoint: string,
     currentUserKey: string,
-    body?: object | string | string[]
+    body?: object | string | string[],
+    contentType = 'application/json'
 ): Promise<T & ApiError> =>
-    await fetch(endpoint, { method: 'DELETE', headers: { 'current_user': currentUserKey }, body: JSON.stringify(body) })
-        .then(res => res.json())
-        .then(res => (res))
+    await fetch(endpoint, { 
+        method: HTTPMethod.DELETE, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        }, 
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.DELETE))
         .catch(err => err);
 
 /**
@@ -189,12 +243,49 @@ export const deleteRequest = async <T> (
 export const deleteRequestAsync = async <T> (
     endpoint: string,
     currentUserKey: string,
-    body?: object | string | string[]
+    body?: object | string | string[],
+    contentType = 'application/json'
 ): Promise<T & ApiError> => 
     await fetch(endpoint, {
-        method: 'DELETE', 
-        headers: { 'current_user': currentUserKey }, 
-        body: JSON.stringify(body) })
-        .then(res => res.json())
+        method: HTTPMethod.DELETE, 
+        headers: { 
+            'current_user': currentUserKey,
+            'Content-Type': contentType
+        },
+        body: stringifyBody(body)
+    })
+        .then(res => handleFetch(res, HTTPMethod.DELETE))
         .then(res => poll<T>(res))
         .catch(err => err);
+
+/**
+ * Helper function for the .then clause of a fetch promise.
+ * Sends an email stack trace if an error is thrown.
+ * 
+ * @param res - the response
+ * @param httpMethod - the HTTPMethod
+ * 
+ * @returns The res.json()
+ */
+const handleFetch = (res: Response, httpMethod: HTTPMethod) => {
+    if (!res.ok) {
+        const error = Error(`${res.status} error from ${httpMethod} ${res.url}`);
+        sendStackTrace(error.stack as string);
+    }
+    return res.json();
+}
+
+/**
+ * Helper function to JSON.stringify the body of a request or keep as a string
+ * if the passed body is already a string.
+ * 
+ * @param body - the body of a request
+ * 
+ * @returns The stringified body
+ */
+const stringifyBody = (body: object | string | string[] | undefined) => {
+    if (typeof body === 'string') {
+        return body
+    }
+    return JSON.stringify(body)
+};
diff --git a/ui/tests/app/about/About.test.tsx b/ui/tests/app/about/About.test.tsx
index 647c83dd..2d6a1af5 100644
--- a/ui/tests/app/about/About.test.tsx
+++ b/ui/tests/app/about/About.test.tsx
@@ -1,4 +1,4 @@
-import {render, screen} from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
 import About from '@/app/about/page';
 
 describe('About', () => {
@@ -14,8 +14,7 @@ describe('About', () => {
 
         expect(screen.getByText('GENERAL INFO')).toBeInTheDocument();
         expect(screen.getByRole('link', {name: 'A request form is available'}))
-            .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/' +
-                                                ' UH+Groupings+Request+Form');
+            .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13402308/UH+Groupings+Request+Form');
         expect(screen.getByRole('link', {name: 'General information about groupings is available'}))
             .toHaveAttribute('href', 'https://uhawaii.atlassian.net/wiki/spaces/UHIAM/pages/13403213/UH+Groupings');
 
diff --git a/ui/tests/services/EmailService.test.ts b/ui/tests/services/EmailService.test.ts
index a59183b8..50d2662c 100644
--- a/ui/tests/services/EmailService.test.ts
+++ b/ui/tests/services/EmailService.test.ts
@@ -1,5 +1,5 @@
 import User from '@/access/User';
-import { Feedback, sendFeedback } from '@/services/EmailService';
+import { Feedback, sendFeedback, sendStackTrace } from '@/services/EmailService';
 import * as AuthenticationService from '@/access/AuthenticationService';
 
 const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string;
@@ -10,7 +10,6 @@ jest.mock('@/access/AuthenticationService');
 describe('EmailService', () => {
 
     const currentUser =  testUser;
-    const headers = { 'current_user': currentUser.uid };
 
     beforeAll(() => {
         jest.spyOn(AuthenticationService, 'getCurrentUser').mockResolvedValue(testUser);
@@ -29,10 +28,30 @@ describe('EmailService', () => {
             await sendFeedback(feedback);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/email/send/feedback`, {
                 body: JSON.stringify(feedback),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
-        
     });
+
+    describe('sendStackTrace', () => {
+
+        const stackTrace = 'stackTrace';
+
+        it('should make a POST request at the correct endpoint', async () => {
+            await sendStackTrace(stackTrace);
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/email/send/stack-trace`, {
+                body: stackTrace,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'text/plain'
+                },
+                method: 'POST'
+            });
+        });
+    });
+    
 });
diff --git a/ui/tests/services/GroupingsApiService.test.ts b/ui/tests/services/GroupingsApiService.test.ts
index 58004c8f..24e28875 100644
--- a/ui/tests/services/GroupingsApiService.test.ts
+++ b/ui/tests/services/GroupingsApiService.test.ts
@@ -42,11 +42,11 @@ const baseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string;
 const testUser: User = JSON.parse(process.env.TEST_USER_A as string);
 
 jest.mock('@/access/AuthenticationService');
+jest.mock('@/services/EmailService');
 
 describe('GroupingsService', () => {
 
     const currentUser =  testUser;
-    const headers = { 'current_user': currentUser.uid };
     
     const uhIdentifier = 'testiwta';
     const uhIdentifiers = ['testiwta', 'testiwtb'];
@@ -117,7 +117,10 @@ describe('GroupingsService', () => {
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/group?`
                 + `page=${page}&size=${size}&sortString=${sortString}&isAscending=${isAscending}`, {
                 body: JSON.stringify(groupPaths),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
@@ -167,7 +170,9 @@ describe('GroupingsService', () => {
     describe('groupingDescription', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await groupingDescription(groupingPath);
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -184,8 +189,9 @@ describe('GroupingsService', () => {
     describe('groupingSyncDest', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await groupingSyncDest(groupingPath);
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/groupings-sync-destinations`,
-                { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/groupings-sync-destinations`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -202,8 +208,9 @@ describe('GroupingsService', () => {
     describe('groupingOptAttributes', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await groupingOptAttributes(groupingPath);
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/opt-attributes`,
-                { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/opt-attributes`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -223,8 +230,11 @@ describe('GroupingsService', () => {
         it('should make a POST request at the correct endpoint', async () => {
             await updateDescription(description, groupingPath);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/description`, {
-                body: JSON.stringify(description),
-                headers,
+                body: description,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
@@ -243,7 +253,9 @@ describe('GroupingsService', () => {
     describe('groupingAdmins', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await groupingAdmins();
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/grouping-admins`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/grouping-admins`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -260,7 +272,9 @@ describe('GroupingsService', () => {
     describe('getAllGroupings', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await getAllGroupings();
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/all-groupings`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/all-groupings`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -280,7 +294,10 @@ describe('GroupingsService', () => {
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members`, {
                     body: JSON.stringify(uhIdentifiers), 
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 });
         });
@@ -310,7 +327,10 @@ describe('GroupingsService', () => {
             expect(fetch).toHaveBeenCalledWith(
                 `${baseUrl}/groupings/${groupingPath}/include-members/async`,{
                     body: JSON.stringify(uhIdentifiers), 
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 });
         });
@@ -347,7 +367,10 @@ describe('GroupingsService', () => {
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members`, {
                     body: JSON.stringify(uhIdentifiers), 
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 });
         });
@@ -377,7 +400,10 @@ describe('GroupingsService', () => {
             expect(fetch).toHaveBeenCalledWith(
                 `${baseUrl}/groupings/${groupingPath}/exclude-members/async`,{
                     body: JSON.stringify(uhIdentifiers), 
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 });
         });
@@ -412,7 +438,10 @@ describe('GroupingsService', () => {
         it('should make a POST request at the correct endpoint', async () => {
             await addOwners(uhIdentifiers, groupingPath);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifiers}`, {
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
@@ -432,7 +461,10 @@ describe('GroupingsService', () => {
         it('should make a POST request at the correct endpoint', async () => {
             await addAdmin(uhIdentifier);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${uhIdentifier}`, {
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
@@ -452,7 +484,10 @@ describe('GroupingsService', () => {
         it('should make a DELETE request at the correct endpoint', async () => {
             await removeFromGroups(uhIdentifier, groupPaths);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${groupPaths}/${uhIdentifier}`, {
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'DELETE'
             });
         });
@@ -473,7 +508,10 @@ describe('GroupingsService', () => {
             await removeIncludeMembers(uhIdentifiers, groupingPath);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members`, {
                 body: JSON.stringify(uhIdentifiers),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'DELETE'
             });
         });
@@ -494,7 +532,10 @@ describe('GroupingsService', () => {
             await removeExcludeMembers(uhIdentifiers, groupingPath);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members`, {
                 body: JSON.stringify(uhIdentifiers),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'DELETE'
             });
         });
@@ -514,7 +555,10 @@ describe('GroupingsService', () => {
         it('should make a DELETE request at the correct endpoint', async () => {
             await removeOwners(uhIdentifiers, groupingPath);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifiers}`, {
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'DELETE'
             });
         });
@@ -534,7 +578,10 @@ describe('GroupingsService', () => {
         it('should make a DELETE request at the correct endpoint', async () => {
             await removeAdmin(uhIdentifier);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/admins/${uhIdentifier}`,  { 
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'DELETE'
             });
         });
@@ -555,7 +602,10 @@ describe('GroupingsService', () => {
             await memberAttributeResults(uhIdentifiers);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members`, {
                 body: JSON.stringify(uhIdentifiers),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST'
             });
         });
@@ -584,7 +634,10 @@ describe('GroupingsService', () => {
             await memberAttributeResultsAsync(uhIdentifiers);
             expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/async`, {
                 body: JSON.stringify(uhIdentifiers),
-                headers,
+                headers: {
+                    'current_user': currentUser.uid,
+                    'Content-Type': 'application/json'
+                },
                 method: 'POST' 
             });
         });
@@ -609,7 +662,7 @@ describe('GroupingsService', () => {
             fetchMock
                 .mockResponseOnce(JSON.stringify(0))
                 .mockRejectOnce(() => Promise.reject(mockError));
-            res = addExcludeMembersAsync(uhIdentifiers, groupingPath);
+            res = memberAttributeResultsAsync(uhIdentifiers);
             await jest.advanceTimersByTimeAsync(5000);
             expect(await res).toEqual(mockError);
         });
@@ -620,7 +673,10 @@ describe('GroupingsService', () => {
             await optIn(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include-members/${currentUser.uid}/self`, {
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 });
         });
@@ -641,7 +697,10 @@ describe('GroupingsService', () => {
             await optOut(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude-members/${currentUser.uid}/self`, {
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'PUT'
                 })
         });
@@ -660,7 +719,9 @@ describe('GroupingsService', () => {
     describe('membershipResults', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await membershipResults();
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -677,7 +738,9 @@ describe('GroupingsService', () => {
     describe('managePersonResults', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await managePersonResults(uhIdentifier);
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${uhIdentifier}/groupings`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${uhIdentifier}/groupings`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -694,7 +757,9 @@ describe('GroupingsService', () => {
     describe('getNumberOfMemberships', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await getNumberOfMemberships();
-            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships/count`, { headers });
+            expect(fetch).toHaveBeenCalledWith(`${baseUrl}/members/${currentUser.uid}/memberships/count`, {
+                headers: { 'current_user': currentUser.uid }
+            });
         });
 
         it('should handle the successful response', async () => {
@@ -712,7 +777,9 @@ describe('GroupingsService', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await optInGroupingPaths();
             expect(fetch)
-                .toHaveBeenCalledWith(`${baseUrl}/groupings/members/${currentUser.uid}/opt-in-groups`, { headers });
+                .toHaveBeenCalledWith(`${baseUrl}/groupings/members/${currentUser.uid}/opt-in-groups`, {
+                    headers: { 'current_user': currentUser.uid }
+                });
         });
 
         it('should handle the successful response', async () => {
@@ -731,7 +798,10 @@ describe('GroupingsService', () => {
             await resetIncludeGroup(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include`, { 
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'DELETE'
                 });
         });
@@ -760,7 +830,10 @@ describe('GroupingsService', () => {
             await resetIncludeGroupAsync(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/include/async`, {
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'DELETE'
                 });
         });
@@ -796,7 +869,10 @@ describe('GroupingsService', () => {
             await resetExcludeGroup(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude`, {
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'DELETE'
                 });
         });
@@ -825,7 +901,10 @@ describe('GroupingsService', () => {
             await resetExcludeGroupAsync(groupingPath);
             expect(fetch)
                 .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/exclude/async`, {
-                    headers,
+                    headers: {
+                        'current_user': currentUser.uid,
+                        'Content-Type': 'application/json'
+                    },
                     method: 'DELETE'
                 });
         });
@@ -860,7 +939,9 @@ describe('GroupingsService', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await groupingOwners(groupingPath);
             expect(fetch)
-                .toHaveBeenCalledWith(`${baseUrl}/grouping/${groupingPath}/owners`, { headers });
+                .toHaveBeenCalledWith(`${baseUrl}/grouping/${groupingPath}/owners`, {
+                    headers: { 'current_user': currentUser.uid }
+                });
         });
 
         it('should handle the successful response', async () => {
@@ -878,7 +959,9 @@ describe('GroupingsService', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await ownerGroupings();
             expect(fetch)
-                .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings`, { headers });
+                .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings`, {
+                    headers: { 'current_user': currentUser.uid }
+                });
         });
 
         it('should handle the successful response', async () => {
@@ -896,7 +979,9 @@ describe('GroupingsService', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await getNumberOfGroupings();
             expect(fetch)
-                .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings/count`, { headers });
+                .toHaveBeenCalledWith(`${baseUrl}/owners/${currentUser.uid}/groupings/count`, {
+                    headers: { 'current_user': currentUser.uid }
+                });
         });
 
         it('should handle the successful response', async () => {
@@ -914,7 +999,9 @@ describe('GroupingsService', () => {
         it('should make a GET request at the correct endpoint', async () => {
             await isSoleOwner(uhIdentifier, groupingPath);
             expect(fetch)
-                .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifier}`, { headers });
+                .toHaveBeenCalledWith(`${baseUrl}/groupings/${groupingPath}/owners/${uhIdentifier}`, {
+                    headers: { 'current_user': currentUser.uid }
+                });
         });
 
         it('should handle the successful response', async () => {

From df7c92e558650b989c211cbfa0c48499ee2d192a Mon Sep 17 00:00:00 2001
From: Jordan Wong <42422209+JorWo@users.noreply.github.com>
Date: Fri, 8 Mar 2024 16:29:39 -1000
Subject: [PATCH 2/2] Create headers for /admin /groupings /memberships (#10)

---
 ui/package.json                               |  1 +
 ui/src/app/about/page.tsx                     |  2 +-
 ui/src/app/admin/page.tsx                     | 58 ++++++++++++
 ui/src/app/groupings/page.tsx                 | 22 +++++
 ui/src/app/memberships/page.tsx               | 46 +++++++++
 ui/src/components/ui/button.tsx               |  8 +-
 ui/src/components/ui/sheet.tsx                | 12 +--
 ui/src/components/ui/tabs.tsx                 | 93 +++++++++++++++++++
 .../about/{About.test.tsx => page.test.tsx}   |  0
 ui/tests/app/admin/page.test.tsx              | 20 ++++
 ui/tests/app/groupings/page.test.tsx          | 15 +++
 ui/tests/app/memberships/page.test.tsx        | 19 ++++
 12 files changed, 285 insertions(+), 11 deletions(-)
 create mode 100644 ui/src/app/admin/page.tsx
 create mode 100644 ui/src/app/groupings/page.tsx
 create mode 100644 ui/src/app/memberships/page.tsx
 create mode 100644 ui/src/components/ui/tabs.tsx
 rename ui/tests/app/about/{About.test.tsx => page.test.tsx} (100%)
 create mode 100644 ui/tests/app/admin/page.test.tsx
 create mode 100644 ui/tests/app/groupings/page.test.tsx
 create mode 100644 ui/tests/app/memberships/page.test.tsx

diff --git a/ui/package.json b/ui/package.json
index 11569645..1a100b52 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -15,6 +15,7 @@
   "dependencies": {
     "@radix-ui/react-dialog": "^1.0.5",
     "@radix-ui/react-slot": "^1.0.2",
+    "@radix-ui/react-tabs": "^1.0.4",
     "camaro": "^6.2.2",
     "class-variance-authority": "^0.7.0",
     "clsx": "^2.1.0",
diff --git a/ui/src/app/about/page.tsx b/ui/src/app/about/page.tsx
index 48302376..c8d17f53 100644
--- a/ui/src/app/about/page.tsx
+++ b/ui/src/app/about/page.tsx
@@ -2,7 +2,7 @@ import Image from 'next/image';
 
 const About = () => (
     <main>
-        <div className="bg-seafoam pt-10 pb-10">
+        <div className="bg-seafoam py-10">
             <div className="container">
                 <div className="grid gap-2">
                     <h1 className="text-center text-4xl font-medium">What is a UH Grouping?</h1>
diff --git a/ui/src/app/admin/page.tsx b/ui/src/app/admin/page.tsx
new file mode 100644
index 00000000..5ba4c690
--- /dev/null
+++ b/ui/src/app/admin/page.tsx
@@ -0,0 +1,58 @@
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
+
+const Admin = () => {
+    return ( 
+        <main>
+            <div className="bg-seafoam pt-3">
+                <div className="container">
+                    <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">UH Groupings Administration</h1>
+                    <p className="pb-8 text-xl text-center md:text-left">
+                        Search for and manage any grouping on behalf of its owner. 
+                        Manage the list of UH Groupings administrators.
+                    </p>
+                </div>
+                <Tabs defaultValue="manage-groupings">
+                    <div className="container">
+                        <TabsList variant="outline">
+                            <TabsTrigger 
+                                value="manage-groupings" variant="outline" >
+                                    Manage Groupings
+                            </TabsTrigger>
+                            <TabsTrigger 
+                                value="manage-admins" variant="outline" >
+                                    Manage Admins
+                            </TabsTrigger>
+                            <TabsTrigger 
+                                value="manage-person" variant="outline" >
+                                    Manage Person
+                            </TabsTrigger>
+                        </TabsList>
+                    </div>
+                    <TabsContent value="manage-groupings">
+                        <div className="bg-white">
+                            <div className="container">
+                                {/* GroupingsTable goes here */}
+                            </div>
+                        </div>
+                    </TabsContent>
+                    <TabsContent value="manage-admins">
+                        <div className="bg-white">
+                            <div className="container">
+                                {/* AdminTable goes here */}
+                            </div>
+                        </div>
+                    </TabsContent>
+                    <TabsContent value="manage-person">
+                        <div className="bg-white">
+                            <div className="container">
+                                {/* PersonTable goes here */}
+                            </div>
+                        </div>
+                    </TabsContent>
+                </Tabs>
+            </div>
+        </main>
+    );
+}
+ 
+export default Admin;
diff --git a/ui/src/app/groupings/page.tsx b/ui/src/app/groupings/page.tsx
new file mode 100644
index 00000000..0824e8fa
--- /dev/null
+++ b/ui/src/app/groupings/page.tsx
@@ -0,0 +1,22 @@
+const Groupings = () => {
+    return ( 
+        <main>
+            <div className="bg-seafoam pt-3">
+                <div className="container">
+                    <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">Manage My Groupings</h1>
+                    <p className="pb-3 text-xl text-center md:text-left">
+                        View and manage groupings I own. Manage members, 
+                        configure grouping options and sync destinations.
+                    </p>
+                </div>
+                <div className="bg-white">
+                    <div className="container">
+                        {/* GroupingsTable goes here */}
+                    </div>
+                </div>
+            </div>
+        </main>
+    );
+}
+ 
+export default Groupings;
diff --git a/ui/src/app/memberships/page.tsx b/ui/src/app/memberships/page.tsx
new file mode 100644
index 00000000..09aa557b
--- /dev/null
+++ b/ui/src/app/memberships/page.tsx
@@ -0,0 +1,46 @@
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+
+const Memberships = () => {
+    return ( 
+        <main>
+            <div className="bg-seafoam pt-3">
+                <div className="container">
+                    <h1 className="mb-1 font-bold text-[2rem] text-center md:text-left">Manage My Memberships</h1>
+                    <p className="pb-8 text-xl text-center md:text-left">
+                        View and manage my memberships. Search for new groupings to join as a member.
+                    </p>
+                </div>
+                <Tabs defaultValue="current-memberships">
+                    <div className="container">
+                        <TabsList variant="outline">
+                            <TabsTrigger 
+                                value="current-memberships" variant="outline">
+                                    Current Memberships
+                            </TabsTrigger>
+                            <TabsTrigger 
+                                value="membership-opportunities" variant="outline">
+                                    Membership Opportunities
+                            </TabsTrigger>
+                        </TabsList>
+                    </div>
+                    <TabsContent value="current-memberships">
+                        <div className="bg-white">
+                            <div className="container">
+                                {/* MembershipsTable goes here */}
+                            </div>
+                        </div>
+                    </TabsContent>
+                    <TabsContent value="membership-opportunities">
+                        <div className="bg-white">
+                            <div className="container">
+                                {/* MembershipsTable goes here */}
+                            </div>
+                        </div>
+                    </TabsContent>
+                </Tabs>
+            </div>
+        </main>
+    );
+}
+ 
+export default Memberships;
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
index 3c469dd0..60a2e796 100644
--- a/ui/src/components/ui/button.tsx
+++ b/ui/src/components/ui/button.tsx
@@ -1,8 +1,8 @@
-import * as React from 'react'
-import { Slot } from '@radix-ui/react-slot'
-import { cva, type VariantProps } from 'class-variance-authority'
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
 
-import { cn } from '@/components/ui/utils'
+import { cn } from '@/components/ui/utils';
 
 const buttonVariants = cva(
     `inline-flex items-center justify-center whitespace-nowrap rounded-[0.25rem] text-base font-normal ring-offset-white
diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx
index 3b2131bc..da7b8731 100644
--- a/ui/src/components/ui/sheet.tsx
+++ b/ui/src/components/ui/sheet.tsx
@@ -1,11 +1,11 @@
-'use client'
+'use client';
 
-import * as React from 'react'
-import * as SheetPrimitive from '@radix-ui/react-dialog'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { X } from 'lucide-react'
+import * as React from 'react';
+import * as SheetPrimitive from '@radix-ui/react-dialog';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { X } from 'lucide-react';
 
-import { cn } from '@/components/ui/utils'
+import { cn } from '@/components/ui/utils';
 
 const Sheet = SheetPrimitive.Root;
 
diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..341e986e
--- /dev/null
+++ b/ui/src/components/ui/tabs.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import * as React from 'react';
+import * as TabsPrimitive from '@radix-ui/react-tabs';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from './utils';
+
+const Tabs = TabsPrimitive.Root
+
+const tabsListVariants = cva(
+    'inline-flex',
+    {
+        variants: {
+            variant: {
+                default: 'items-center justify-center h-10 rounded-md bg-muted p-1 text-muted-foreground',
+                outline: '',
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+        },
+    }
+)
+const tabsTriggerVariants = cva(
+    'inline-flex items-center justify-center',
+    {
+        variants: {
+            variant: {
+                default: `whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background 
+                    transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 
+                    focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 
+                    data-[state=active]:bg-background data-[state=active]:text-foreground 
+                    data-[state=active]:shadow-sm`,
+                outline: `px-4 py-2 text-base font-medium ring-offset-background transition-all 
+                    data-[state=active]:bg-white data-[state=active]:rounded-t data-[state=active]:border-b-5 
+                    data-[state=active]:border-black data-[state=active]:text-foreground text-muted-foreground`,
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+        },
+    }
+)
+interface TabsListProps
+    extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
+    VariantProps<typeof tabsListVariants> {}
+
+const TabsList = React.forwardRef<
+    React.ElementRef<typeof TabsPrimitive.List>,
+    TabsListProps
+>(({ className, variant, ...props }, ref) => (
+    <TabsPrimitive.List
+        ref={ref}
+        className={cn(tabsListVariants({ variant, className }))}
+        {...props}
+    />
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+interface TabsTriggerProps
+    extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>,
+    VariantProps<typeof tabsTriggerVariants> {}
+
+const TabsTrigger = React.forwardRef<
+    React.ElementRef<typeof TabsPrimitive.Trigger>,
+    TabsTriggerProps
+>(({ className, variant, ...props }, ref) => (
+    <TabsPrimitive.Trigger
+        ref={ref}
+        className={cn(tabsTriggerVariants({ variant, className }))}
+        {...props}
+    />
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+
+const TabsContent = React.forwardRef<
+    React.ElementRef<typeof TabsPrimitive.Content>,
+    React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => (
+    <TabsPrimitive.Content
+        ref={ref}
+        className={cn(
+            `ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 
+            focus-visible:ring-offset-2`,
+            className
+        )}
+        {...props}
+    />
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/ui/tests/app/about/About.test.tsx b/ui/tests/app/about/page.test.tsx
similarity index 100%
rename from ui/tests/app/about/About.test.tsx
rename to ui/tests/app/about/page.test.tsx
diff --git a/ui/tests/app/admin/page.test.tsx b/ui/tests/app/admin/page.test.tsx
new file mode 100644
index 00000000..f2f4cd68
--- /dev/null
+++ b/ui/tests/app/admin/page.test.tsx
@@ -0,0 +1,20 @@
+import Admin from '@/app/admin/page';
+import { render, screen } from '@testing-library/react';
+
+describe('Admin', () => {
+
+    it('should render the Admin page with the appropriate header and tabs', () => {
+        render(<Admin />);
+        expect(screen.getByRole('main')).toBeInTheDocument();
+
+        expect(screen.getByRole('heading', { name: 'UH Groupings Administration' })).toBeInTheDocument();
+        expect(screen.getByText('Search for and manage any grouping on behalf of its owner. ' + 
+            'Manage the list of UH Groupings administrators.')).toBeInTheDocument();
+
+        expect(screen.getByRole('tablist')).toBeInTheDocument();
+        expect(screen.getByRole('tab', { name: 'Manage Groupings' })).toBeInTheDocument();
+        expect(screen.getByRole('tab', { name: 'Manage Admins' })).toBeInTheDocument();
+        expect(screen.getByRole('tab', { name: 'Manage Person' })).toBeInTheDocument();
+    });
+
+});
diff --git a/ui/tests/app/groupings/page.test.tsx b/ui/tests/app/groupings/page.test.tsx
new file mode 100644
index 00000000..8562eda6
--- /dev/null
+++ b/ui/tests/app/groupings/page.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@testing-library/react';
+import Groupings from '@/app/groupings/page';
+
+describe('Groupings', () => {
+
+    it('should render the Groupings page with the appropriate header', () => {
+        render(<Groupings />);
+        expect(screen.getByRole('main')).toBeInTheDocument();
+
+        expect(screen.getByRole('heading', { name: 'Manage My Groupings' })).toBeInTheDocument();
+        expect(screen.getByText('View and manage groupings I own. ' +
+            'Manage members, configure grouping options and sync destinations.')).toBeInTheDocument();
+    });
+
+});
diff --git a/ui/tests/app/memberships/page.test.tsx b/ui/tests/app/memberships/page.test.tsx
new file mode 100644
index 00000000..54e8d714
--- /dev/null
+++ b/ui/tests/app/memberships/page.test.tsx
@@ -0,0 +1,19 @@
+import Memberships from '@/app/memberships/page';
+import { render, screen } from '@testing-library/react';
+
+describe('Memberships', () => {
+
+    it('should render the Memberhsips page with the appropriate header and tabs', () => {
+        render(<Memberships />);
+        expect(screen.getByRole('main')).toBeInTheDocument();
+
+        expect(screen.getByRole('heading', { name: 'Manage My Memberships' })).toBeInTheDocument();
+        expect(screen.getByText('View and manage my memberships. ' + 
+            'Search for new groupings to join as a member.')).toBeInTheDocument();
+
+        expect(screen.getByRole('tablist')).toBeInTheDocument();
+        expect(screen.getByRole('tab', { name: 'Current Memberships' })).toBeInTheDocument();
+        expect(screen.getByRole('tab', { name: 'Membership Opportunities' })).toBeInTheDocument();
+    });
+
+});