diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx index f620b6a..6a67c3b 100644 --- a/app/(app)/(tabs)/profile.tsx +++ b/app/(app)/(tabs)/profile.tsx @@ -3,7 +3,14 @@ * UI/UX design by Sandith Hewage (Y STEM and Chess) */ -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { theme } from '@/design/theme'; @@ -16,6 +23,10 @@ import { } from '@/design/tokens'; import { useSession } from '@/providers/SessionProvider'; +import { ProfileError } from '@/features/profile/api/profile.errors'; +import type { Profile } from '@/features/profile/api/profile.types'; +import { useProfile } from '@/features/profile/hooks/useProfile'; + type Badge = { id: string; emoji: string; @@ -132,12 +143,8 @@ const MENU_SECTIONS: { title: string; items: MenuItem[] }[] = [ ]; export default function ProfileRoute() { - const { session, signOut } = useSession(); - const displayName = - session.status === 'authenticated' ? session.user.displayName : 'Student'; - const role = - session.status === 'authenticated' ? session.user.role : 'student'; - const initial = displayName.charAt(0).toUpperCase(); + const { signOut } = useSession(); + const { data: profile, isLoading, error, refetch, isFetching } = useProfile(); const earnedCount = BADGES.filter((b) => b.earned).length; return ( @@ -147,27 +154,23 @@ export default function ProfileRoute() { showsVerticalScrollIndicator={false} > {/* ── Hero ───────────────────────────────── */} - - - - {initial} - - - {displayName} - - {role === 'mentor' ? '🎓' : '🎒'} - - {role === 'mentor' ? 'Mentor' : 'Student'} - + {isLoading ? ( + + + Loading your profile… - - - 250 XP - - · - Level 3 - - + ) : profile ? ( + <> + + + + ) : ( + refetch()} + /> + )} {/* ── Stats ──────────────────────────────── */} @@ -273,6 +276,103 @@ export default function ProfileRoute() { ); } +function ProfileHero({ profile }: { profile: Profile }) { + const name = profile.fullName ?? profile.username; + const initial = name.charAt(0).toUpperCase(); + const isMentor = profile.role === 'mentor'; + + return ( + + + + {initial} + + + {name} + + {isMentor ? '🎓' : '🎒'} + {isMentor ? 'Mentor' : 'Student'} + + + + 250 XP + + · + Level 3 + + + ); +} + +function ProfileDetails({ profile }: { profile: Profile }) { + const rows = [ + { label: 'Username', value: profile.username }, + { label: 'Role', value: profile.role === 'mentor' ? 'Mentor' : 'Student' }, + { label: 'School', value: profile.school }, + { label: 'Grade level', value: profile.gradeLevel }, + { label: 'Location', value: profile.location, hideWhenEmpty: true }, + ].filter((r) => r.value || !r.hideWhenEmpty); + + return ( + + Profile + + {rows.map((row, i) => ( + + {row.label} + + {row.value ?? 'Not provided'} + + + ))} + + + ); +} + +function ProfileLoadError({ + error, + retrying, + onRetry, +}: { + error: unknown; + retrying: boolean; + onRetry: () => void; +}) { + const kind = error instanceof ProfileError ? error.kind : 'network'; + const message = + kind === 'session-expired' + ? 'Your session has expired. Please sign in again.' + : kind === 'invalid-data' + ? "We couldn't read your profile information." + : "We couldn't load your profile. Please try again."; + + return ( + + {message} + {kind !== 'session-expired' && ( + [ + s.retryBtn, + (pressed || retrying) && s.retryBtnPressed, + ]} + > + + {retrying ? 'Retrying…' : 'Try again'} + + + )} + + ); +} + const SHADOW = { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, @@ -480,4 +580,52 @@ const s = StyleSheet.create({ fontWeight: fontWeights.semibold, color: palette.error, }, + + /* Profile status + details */ + statusBox: { + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.xl, + }, + statusText: { + fontSize: fontSizes.label, + color: palette.muted, + textAlign: 'center', + }, + retryBtn: { + backgroundColor: palette.brandGreen, + borderRadius: radii.lg, + minHeight: 48, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, + retryBtnPressed: { opacity: 0.7 }, + retryText: { + fontSize: fontSizes.label, + fontWeight: fontWeights.semibold, + color: palette.white, + }, + detailRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + }, + detailLabel: { fontSize: fontSizes.label, color: palette.muted }, + detailValue: { + flexShrink: 1, + fontSize: fontSizes.label, + fontWeight: fontWeights.semibold, + color: palette.ink, + textAlign: 'right', + }, + detailValueMissing: { + flexShrink: 1, + fontSize: fontSizes.label, + color: palette.muted, + textAlign: 'right', + }, }); diff --git a/src/features/profile/api/ApiProfileGateway.ts b/src/features/profile/api/ApiProfileGateway.ts new file mode 100644 index 0000000..c7e6d43 --- /dev/null +++ b/src/features/profile/api/ApiProfileGateway.ts @@ -0,0 +1,9 @@ +import type { ProfileGateway } from './profile.gateway'; + +export function createApiProfileGateway(): ProfileGateway { + return { + async getProfile() { + throw new Error('ApiProfileGateway is not connected yet'); + }, + }; +} diff --git a/src/features/profile/api/fakeProfileGateway.ts b/src/features/profile/api/fakeProfileGateway.ts new file mode 100644 index 0000000..1683730 --- /dev/null +++ b/src/features/profile/api/fakeProfileGateway.ts @@ -0,0 +1,66 @@ +import { ProfileError } from './profile.errors'; +import { ProfileGateway } from './profile.gateway'; +import { parseProfile } from './profile.mapper'; + +export type FakeScenario = + | 'student' + | 'mentor' + | 'missing-data' + | 'invalid-data' + | 'failure' + | 'session-expired'; + +const samples = { + student: { + username: 'sample.student', + role: 'student', + first_name: 'Sample', + last_name: 'Student', + email: 'sample.student@example.invalid', + zip_code: '00000', + gender: 'Prefer not to say', + grade_level: '10', + country: 'Sampleland', + state: 'Sample State', + school: 'Sample High School', + }, + mentor: { + username: 'sample.mentor', + role: 'mentor', + first_name: 'Sample', + last_name: 'Mentor', + email: 'sample.mentor@example.invalid', + country: 'Sampleland', + state: 'Sample State', + school: 'Sample University', + }, + 'missing-data': { username: 'sample.minimal', role: 'student' }, + 'invalid-data': { username: 123, role: 'wizard' }, +}; + +export function createFakeProfileGateway( + opts: { + scenario?: FakeScenario; + delayMs?: number; + failTimes?: number; + } = {}, +): ProfileGateway { + const { scenario = 'student', delayMs = 500, failTimes = 0 } = opts; + let failuresLeft = failTimes; + const wait = () => new Promise((r) => setTimeout(r, delayMs)); + + return { + async getProfile() { + await wait(); + if (failuresLeft > 0) { + failuresLeft -= 1; + throw new ProfileError('network', 'Fake network failure'); + } + if (scenario === 'failure') + throw new ProfileError('network', 'Fake network failure'); + if (scenario === 'session-expired') + throw new ProfileError('session-expired', 'Fake expired session'); + return parseProfile(samples[scenario]); + }, + }; +} diff --git a/src/features/profile/api/gatewayProvider.ts b/src/features/profile/api/gatewayProvider.ts new file mode 100644 index 0000000..d9f44f2 --- /dev/null +++ b/src/features/profile/api/gatewayProvider.ts @@ -0,0 +1,6 @@ +import type { ProfileGateway } from './profile.gateway'; +import { createFakeProfileGateway } from './fakeProfileGateway'; + +export const profileGateway: ProfileGateway = createFakeProfileGateway({ + scenario: 'student', +}); diff --git a/src/features/profile/api/profile.errors.ts b/src/features/profile/api/profile.errors.ts new file mode 100644 index 0000000..1f94fc9 --- /dev/null +++ b/src/features/profile/api/profile.errors.ts @@ -0,0 +1,11 @@ +export type ProfileErrorKind = 'network' | 'invalid-data' | 'session-expired'; + +export class ProfileError extends Error { + constructor( + public kind: ProfileErrorKind, + message: string, + ) { + super(message); + this.name = 'ProfileError'; + } +} diff --git a/src/features/profile/api/profile.gateway.ts b/src/features/profile/api/profile.gateway.ts new file mode 100644 index 0000000..82b7fce --- /dev/null +++ b/src/features/profile/api/profile.gateway.ts @@ -0,0 +1,5 @@ +import { Profile } from './profile.types'; + +export interface ProfileGateway { + getProfile(): Promise; +} diff --git a/src/features/profile/api/profile.mapper.ts b/src/features/profile/api/profile.mapper.ts new file mode 100644 index 0000000..babaa76 --- /dev/null +++ b/src/features/profile/api/profile.mapper.ts @@ -0,0 +1,40 @@ +import { ProfileResponseSchema, type ProfileResponse } from './profile.schema'; +import { ProfileError } from './profile.errors'; +import type { Profile } from './profile.types'; + +const clean = (v?: string | null) => (v?.trim() ? v.trim() : null); +const join = (parts: (string | null)[], sep: string) => + parts.filter(Boolean).join(sep) || null; + +export function mapProfile(r: ProfileResponse): Profile { + const firstName = clean(r.first_name); + const lastName = clean(r.last_name); + const state = clean(r.state); + const country = clean(r.country); + return { + username: r.username, + role: r.role, + firstName, + lastName, + fullName: join([firstName, lastName], ' '), + email: clean(r.email), + zipCode: clean(r.zip_code), + gender: clean(r.gender), + gradeLevel: clean(r.grade_level), + country, + state, + school: clean(r.school), + location: join([state, country], ', '), + }; +} + +export function parseProfile(raw: unknown): Profile { + const result = ProfileResponseSchema.safeParse(raw); + if (!result.success) { + throw new ProfileError( + 'invalid-data', + 'Profile data was not in the expected format', + ); + } + return mapProfile(result.data); +} diff --git a/src/features/profile/api/profile.schema.ts b/src/features/profile/api/profile.schema.ts new file mode 100644 index 0000000..e3c1c82 --- /dev/null +++ b/src/features/profile/api/profile.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const ProfileResponseSchema = z.object({ + username: z.string().min(1), + role: z + .string() + .transform((s) => s.toLowerCase()) + .pipe(z.enum(['student', 'mentor'])), + first_name: z.string().nullish(), + last_name: z.string().nullish(), + email: z.string().nullish(), + zip_code: z.string().nullish(), + gender: z.string().nullish(), + grade_level: z.string().nullish(), + country: z.string().nullish(), + state: z.string().nullish(), + school: z.string().nullish(), +}); + +export type ProfileResponse = z.infer; diff --git a/src/features/profile/api/profile.types.ts b/src/features/profile/api/profile.types.ts new file mode 100644 index 0000000..3051ce5 --- /dev/null +++ b/src/features/profile/api/profile.types.ts @@ -0,0 +1,17 @@ +export type ProfileRole = 'student' | 'mentor'; + +export type Profile = { + username: string; + role: ProfileRole; + firstName: string | null; + lastName: string | null; + fullName: string | null; + email: string | null; + zipCode: string | null; + gender: string | null; + gradeLevel: string | null; + country: string | null; + state: string | null; + school: string | null; + location: string | null; +}; diff --git a/src/features/profile/hooks/useProfile.ts b/src/features/profile/hooks/useProfile.ts new file mode 100644 index 0000000..76d1ae3 --- /dev/null +++ b/src/features/profile/hooks/useProfile.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { profileGateway } from '../api/gatewayProvider'; + +export const profileQueryKey = ['profile'] as const; + +export function useProfile() { + return useQuery({ + queryKey: profileQueryKey, + queryFn: () => profileGateway.getProfile(), + }); +} diff --git a/test/ProfileScreen.test.tsx b/test/ProfileScreen.test.tsx new file mode 100644 index 0000000..356d411 --- /dev/null +++ b/test/ProfileScreen.test.tsx @@ -0,0 +1,125 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import ProfileRoute from '../app/(app)/(tabs)/profile'; +import { createFakeProfileGateway } from '../src/features/profile/api/fakeProfileGateway'; +import type { ProfileGateway } from '../src/features/profile/api/profile.gateway'; + +let mockGateway: ProfileGateway; +const mockSignOut = jest.fn(); + +jest.mock('@/features/profile/api/gatewayProvider', () => ({ + get profileGateway() { + return mockGateway; + }, +})); + +jest.mock('@/providers/SessionProvider', () => ({ + useSession: () => ({ + session: { status: 'authenticated' }, + signOut: mockSignOut, + }), +})); + +let queryClient: QueryClient; + +function renderScreen( + options: Parameters[0] = {}, +) { + mockGateway = createFakeProfileGateway({ delayMs: 0, ...options }); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +const LOAD_ERROR = "We couldn't load your profile. Please try again."; + +beforeEach(() => mockSignOut.mockClear()); + +afterEach(() => { + queryClient?.clear(); +}); + +describe('Profile screen', () => { + test('shows a loading state, then the profile', async () => { + renderScreen(); + expect(screen.getByLabelText('Loading profile')).toBeTruthy(); + expect(await screen.findByText('Sample Student')).toBeTruthy(); + expect(screen.queryByLabelText('Loading profile')).toBeNull(); + }, 30000); + + test('displays a valid student profile', async () => { + renderScreen({ scenario: 'student' }); + expect(await screen.findByText('Sample Student')).toBeTruthy(); + expect(screen.getByText('sample.student')).toBeTruthy(); + expect(screen.getAllByText('Student').length).toBeGreaterThan(0); + expect(screen.getByText('Sample High School')).toBeTruthy(); + expect(screen.getByText('10')).toBeTruthy(); + expect(screen.getByText('Sample State, Sampleland')).toBeTruthy(); + }); + + test('displays a valid mentor profile', async () => { + renderScreen({ scenario: 'mentor' }); + expect(await screen.findByText('Sample Mentor')).toBeTruthy(); + expect(screen.getAllByText('Mentor').length).toBeGreaterThan(0); + expect(screen.queryByText('Student')).toBeNull(); + }); + + test('missing optional info shows "Not provided" or omits the row, without crashing', async () => { + renderScreen({ scenario: 'missing-data' }); + expect((await screen.findAllByText('Not provided')).length).toBeGreaterThan( + 0, + ); + expect(screen.queryByText('Location')).toBeNull(); + }); + + test('shows a useful error when loading fails', async () => { + renderScreen({ scenario: 'failure' }); + expect(await screen.findByText(LOAD_ERROR)).toBeTruthy(); + }); + + test('user can retry after an error', async () => { + renderScreen({ failTimes: 1 }); + expect(await screen.findByText(LOAD_ERROR)).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('Try again')); + + expect(await screen.findByText('Sample Student')).toBeTruthy(); + expect(screen.queryByText(LOAD_ERROR)).toBeNull(); + }); + + test('handles invalid profile data safely', async () => { + renderScreen({ scenario: 'invalid-data' }); + expect( + await screen.findByText("We couldn't read your profile information."), + ).toBeTruthy(); + }); + + test('shows the expired session state without a retry button', async () => { + renderScreen({ scenario: 'session-expired' }); + expect( + await screen.findByText( + 'Your session has expired. Please sign in again.', + ), + ).toBeTruthy(); + expect(screen.queryByLabelText('Try again')).toBeNull(); + }); + + test('Sign out works when the profile loaded', async () => { + renderScreen(); + await screen.findByText('Sample Student'); + fireEvent.press(screen.getByLabelText('Sign out')); + expect(mockSignOut).toHaveBeenCalledTimes(1); + }); + + test('Sign out still works when loading failed', async () => { + renderScreen({ scenario: 'failure' }); + await screen.findByText(LOAD_ERROR); + fireEvent.press(screen.getByLabelText('Sign out')); + expect(mockSignOut).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/fakeProfileGateway.test.ts b/test/fakeProfileGateway.test.ts new file mode 100644 index 0000000..b932ae3 --- /dev/null +++ b/test/fakeProfileGateway.test.ts @@ -0,0 +1,51 @@ +import { createFakeProfileGateway } from '../src/features/profile/api/fakeProfileGateway'; + +const make = (opts = {}) => createFakeProfileGateway({ delayMs: 0, ...opts }); + +describe('FakeProfileGateway', () => { + test('student scenario', async () => { + const p = await make({ scenario: 'student' }).getProfile(); + expect(p.role).toBe('student'); + expect(p.gradeLevel).not.toBeNull(); + }); + + test('mentor scenario', async () => { + expect((await make({ scenario: 'mentor' }).getProfile()).role).toBe( + 'mentor', + ); + }); + + test('missing-data scenario has null optional fields', async () => { + const p = await make({ scenario: 'missing-data' }).getProfile(); + expect(p.school).toBeNull(); + expect(p.location).toBeNull(); + }); + + test('failure scenario rejects with a network error', async () => { + await expect( + make({ scenario: 'failure' }).getProfile(), + ).rejects.toMatchObject({ kind: 'network' }); + }); + + test('invalid-data scenario is rejected by validation', async () => { + await expect( + make({ scenario: 'invalid-data' }).getProfile(), + ).rejects.toMatchObject({ kind: 'invalid-data' }); + }); + + test('session-expired scenario', async () => { + await expect( + make({ scenario: 'session-expired' }).getProfile(), + ).rejects.toMatchObject({ kind: 'session-expired' }); + }); + + test('failTimes fails the first N calls, then succeeds', async () => { + const gateway = make({ failTimes: 1 }); + await expect(gateway.getProfile()).rejects.toMatchObject({ + kind: 'network', + }); + await expect(gateway.getProfile()).resolves.toMatchObject({ + role: 'student', + }); + }); +}); diff --git a/test/navigation.test.tsx b/test/navigation.test.tsx index 6b9bd83..8c297e8 100644 --- a/test/navigation.test.tsx +++ b/test/navigation.test.tsx @@ -127,10 +127,13 @@ describe('session-aware navigation', () => { router.push('/profile'); }); - await waitFor(() => { - expect(renderedRouter.getPathname()).toBe('/profile'); - expect(screen.getByText('Mock Student')).toBeOnTheScreen(); - }); + await waitFor( + () => { + expect(renderedRouter.getPathname()).toBe('/profile'); + expect(screen.getByText('Sample Student')).toBeOnTheScreen(); + }, + { timeout: 3000 }, + ); fireEvent.press( screen.getByRole('button', { diff --git a/test/profile.mapper.test.ts b/test/profile.mapper.test.ts new file mode 100644 index 0000000..32245ac --- /dev/null +++ b/test/profile.mapper.test.ts @@ -0,0 +1,50 @@ +import { + mapProfile, + parseProfile, +} from '../src/features/profile/api/profile.mapper'; +import { ProfileError } from '../src/features/profile/api/profile.errors'; + +describe('mapProfile', () => { + test('builds fullName and location', () => { + const p = mapProfile({ + username: 'a', + role: 'student', + first_name: 'Sample', + last_name: 'Student', + state: 'Sample State', + country: 'Sampleland', + }); + expect(p.fullName).toBe('Sample Student'); + expect(p.location).toBe('Sample State, Sampleland'); + }); + + test('turns missing and blank values into null', () => { + const p = mapProfile({ username: 'a', role: 'student', school: ' ' }); + expect(p.fullName).toBeNull(); + expect(p.location).toBeNull(); + expect(p.school).toBeNull(); + expect(p.gradeLevel).toBeNull(); + }); + + test('uses whatever parts of the name exist', () => { + expect( + mapProfile({ username: 'a', role: 'mentor', first_name: 'Sample' }) + .fullName, + ).toBe('Sample'); + }); +}); + +describe('parseProfile', () => { + test('returns a Profile for valid data', () => { + expect(parseProfile({ username: 'a', role: 'mentor' }).role).toBe('mentor'); + }); + + test('throws an invalid-data ProfileError for bad data', () => { + expect(() => parseProfile({ username: 123 })).toThrow(ProfileError); + try { + parseProfile({ username: 123 }); + } catch (e) { + expect((e as ProfileError).kind).toBe('invalid-data'); + } + }); +}); diff --git a/test/profile.schema.test.ts b/test/profile.schema.test.ts new file mode 100644 index 0000000..4e5cdda --- /dev/null +++ b/test/profile.schema.test.ts @@ -0,0 +1,45 @@ +import { ProfileResponseSchema } from '../src/features/profile/api/profile.schema'; + +const student = { + username: 'sample.student', + role: 'student', + first_name: 'Sample', + last_name: 'Student', + email: 'sample.student@example.invalid', + school: 'Sample High School', + grade_level: '10', +}; + +describe('ProfileResponseSchema', () => { + test('accepts a valid student', () => { + expect(ProfileResponseSchema.safeParse(student).success).toBe(true); + }); + + test('accepts a valid mentor, and role in any letter case', () => { + const result = ProfileResponseSchema.safeParse({ + ...student, + role: 'MENTOR', + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.role).toBe('mentor'); + }); + + test('accepts missing and null optional fields', () => { + expect( + ProfileResponseSchema.safeParse({ + username: 'a', + role: 'student', + school: null, + }).success, + ).toBe(true); + }); + + test.each([ + ['missing username', { role: 'student' }], + ['wrong type', { username: 123, role: 'student' }], + ['unknown role', { username: 'a', role: 'wizard' }], + ['not an object', null], + ])('rejects %s', (_name, data) => { + expect(ProfileResponseSchema.safeParse(data).success).toBe(false); + }); +});