Skip to content

Commit f628bb2

Browse files
authored
feat: update address #2452 (#2674)
Signed-off-by: Mac Deluca <Mac.Deluca@quartech.com>
1 parent 29722df commit f628bb2

File tree

11 files changed

+326
-160
lines changed

11 files changed

+326
-160
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const getAccount = jest.fn()
22
const removeAccount = jest.fn()
3+
const createQuickLoginJWT = jest.fn()
34

4-
export { getAccount, removeAccount }
5+
export { getAccount, removeAccount, createQuickLoginJWT }
Lines changed: 156 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,186 @@
11
import * as useApi from '@/bcsc-theme/api/hooks/useApi'
22
import * as useBCSCApiClient from '@/bcsc-theme/hooks/useBCSCApiClient'
3-
import useDataLoader from '@/bcsc-theme/hooks/useDataLoader'
3+
import * as BcscCore from 'react-native-bcsc-core'
4+
import * as tokens from '@/bcsc-theme/utils/push-notification-tokens'
45
import { useQuickLoginURL } from '@/bcsc-theme/hooks/useQuickLoginUrl'
56
import * as Bifold from '@bifold/core'
6-
import { renderHook, waitFor } from '@testing-library/react-native'
7+
import { renderHook } from '@testing-library/react-native'
78

89
jest.mock('@bifold/core')
910
jest.mock('@/bcsc-theme/api/hooks/useApi')
1011
jest.mock('@/bcsc-theme/hooks/useBCSCApiClient')
11-
jest.mock('@/bcsc-theme/hooks/useDataLoader')
12+
jest.mock('@bcsc-theme/utils/push-notification-tokens')
1213

1314
describe('useQuickLoginURL', () => {
1415
beforeEach(() => {
1516
jest.resetAllMocks()
1617
})
1718

18-
it('should return no url or error when data loaders not ready', async () => {
19+
it('should return error when no initiate login uri', async () => {
1920
const useApiMock = jest.mocked(useApi)
2021
const useClientMock = jest.mocked(useBCSCApiClient)
2122
const bifoldMock = jest.mocked(Bifold)
22-
const useDataLoaderMock = jest.mocked(useDataLoader)
2323

2424
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: jest.fn() } } as any)
2525
useClientMock.useBCSCApiClient.mockReturnValue({} as any)
2626
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
2727

28-
const jwkLoaderMock: any = { load: jest.fn(), isReady: false, data: 'A' }
29-
const tokensLoaderMock: any = { load: jest.fn(), isReady: true, data: 'B' }
30-
const accountLoaderMock: any = { load: jest.fn(), isReady: true, data: 'C' }
28+
const hook = renderHook(() => useQuickLoginURL())
29+
const result = await hook.result.current({ client_ref_id: 'test' })
3130

32-
useDataLoaderMock
33-
.mockReturnValueOnce(jwkLoaderMock)
34-
.mockReturnValueOnce(tokensLoaderMock)
35-
.mockReturnValueOnce(accountLoaderMock)
31+
expect(result).toEqual({ success: false, error: expect.stringContaining('login unavailable') })
32+
})
3633

37-
const hook = renderHook(() => useQuickLoginURL({ client_ref_id: '' }))
34+
it('should return error when no client access token', async () => {
35+
const useApiMock = jest.mocked(useApi)
36+
const useClientMock = jest.mocked(useBCSCApiClient)
37+
const bifoldMock = jest.mocked(Bifold)
3838

39-
// Wait for useEffect to run
40-
await waitFor(() => {
41-
const [url, error] = hook.result.current
39+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: jest.fn() } } as any)
40+
useClientMock.useBCSCApiClient.mockReturnValue({} as any)
41+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
4242

43-
expect(url).toBeNull()
44-
expect(error).toBeNull()
45-
})
43+
const hook = renderHook(() => useQuickLoginURL())
44+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
4645

47-
// Ensure load functions were called
48-
expect(jwkLoaderMock.load).toHaveBeenCalledTimes(1)
49-
expect(tokensLoaderMock.load).toHaveBeenCalledTimes(1)
50-
expect(accountLoaderMock.load).toHaveBeenCalledTimes(1)
46+
expect(result).toEqual({ success: false, error: expect.stringContaining('access token') })
5147
})
5248

53-
// TODO (MD): Add more tests (having difficulty with dataLoader mocks...)
49+
it('should return error when no notification tokens available', async () => {
50+
const useApiMock = jest.mocked(useApi)
51+
const useClientMock = jest.mocked(useBCSCApiClient)
52+
const bifoldMock = jest.mocked(Bifold)
53+
const bcscCoreMock = jest.mocked(BcscCore)
54+
const tokensMock = jest.mocked(tokens)
55+
56+
const getFirstJwkMock = jest.fn()
57+
bcscCoreMock.getAccount = jest.fn()
58+
tokensMock.getNotificationTokens = jest.fn()
59+
60+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: getFirstJwkMock } } as any)
61+
useClientMock.useBCSCApiClient.mockReturnValue({ tokens: { access_token: true } } as any)
62+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
63+
64+
const hook = renderHook(() => useQuickLoginURL())
65+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
66+
67+
expect(result).toEqual({ success: false, error: expect.stringContaining('notification tokens') })
68+
69+
expect(tokensMock.getNotificationTokens).toHaveBeenCalledTimes(1)
70+
expect(bcscCoreMock.getAccount).toHaveBeenCalledTimes(1)
71+
expect(getFirstJwkMock).toHaveBeenCalledTimes(1)
72+
})
73+
74+
it('should return error when no account available', async () => {
75+
const useApiMock = jest.mocked(useApi)
76+
const useClientMock = jest.mocked(useBCSCApiClient)
77+
const bifoldMock = jest.mocked(Bifold)
78+
const bcscCoreMock = jest.mocked(BcscCore)
79+
const tokensMock = jest.mocked(tokens)
80+
81+
const getFirstJwkMock = jest.fn()
82+
bcscCoreMock.getAccount = jest.fn()
83+
tokensMock.getNotificationTokens = jest.fn().mockResolvedValue(true)
84+
85+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: getFirstJwkMock } } as any)
86+
useClientMock.useBCSCApiClient.mockReturnValue({ tokens: { access_token: true } } as any)
87+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
88+
89+
const hook = renderHook(() => useQuickLoginURL())
90+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
91+
92+
expect(result).toEqual({ success: false, error: expect.stringContaining('account') })
93+
94+
expect(tokensMock.getNotificationTokens).toHaveBeenCalledTimes(1)
95+
expect(bcscCoreMock.getAccount).toHaveBeenCalledTimes(1)
96+
expect(getFirstJwkMock).toHaveBeenCalledTimes(1)
97+
})
98+
99+
it('should return error when no jwk available', async () => {
100+
const useApiMock = jest.mocked(useApi)
101+
const useClientMock = jest.mocked(useBCSCApiClient)
102+
const bifoldMock = jest.mocked(Bifold)
103+
const bcscCoreMock = jest.mocked(BcscCore)
104+
const tokensMock = jest.mocked(tokens)
105+
106+
const getFirstJwkMock = jest.fn()
107+
bcscCoreMock.getAccount = jest.fn().mockResolvedValue(true)
108+
tokensMock.getNotificationTokens = jest.fn().mockResolvedValue(true)
109+
110+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: getFirstJwkMock } } as any)
111+
useClientMock.useBCSCApiClient.mockReturnValue({ tokens: { access_token: true } } as any)
112+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
113+
114+
const hook = renderHook(() => useQuickLoginURL())
115+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
116+
117+
expect(result).toEqual({ success: false, error: expect.stringContaining('JWK') })
118+
119+
expect(tokensMock.getNotificationTokens).toHaveBeenCalledTimes(1)
120+
expect(bcscCoreMock.getAccount).toHaveBeenCalledTimes(1)
121+
expect(getFirstJwkMock).toHaveBeenCalledTimes(1)
122+
})
123+
124+
it('should return error when failed to create quick login JWT', async () => {
125+
const useApiMock = jest.mocked(useApi)
126+
const useClientMock = jest.mocked(useBCSCApiClient)
127+
const bifoldMock = jest.mocked(Bifold)
128+
const bcscCoreMock = jest.mocked(BcscCore)
129+
const tokensMock = jest.mocked(tokens)
130+
131+
const getFirstJwkMock = jest.fn().mockResolvedValue(true)
132+
bcscCoreMock.getAccount = jest.fn().mockResolvedValue(true)
133+
tokensMock.getNotificationTokens = jest.fn().mockResolvedValue(true)
134+
bcscCoreMock.createQuickLoginJWT = jest.fn().mockRejectedValue(new Error('failed jwt'))
135+
136+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: getFirstJwkMock } } as any)
137+
useClientMock.useBCSCApiClient.mockReturnValue({ tokens: { access_token: true } } as any)
138+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
139+
140+
const hook = renderHook(() => useQuickLoginURL())
141+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
142+
143+
expect(result).toEqual({ success: false, error: expect.stringContaining('failed jwt') })
144+
145+
expect(tokensMock.getNotificationTokens).toHaveBeenCalledTimes(1)
146+
expect(bcscCoreMock.getAccount).toHaveBeenCalledTimes(1)
147+
expect(getFirstJwkMock).toHaveBeenCalledTimes(1)
148+
expect(bcscCoreMock.createQuickLoginJWT).toHaveBeenCalledTimes(1)
149+
})
150+
151+
it('should return the quick login URL', async () => {
152+
const useApiMock = jest.mocked(useApi)
153+
const useClientMock = jest.mocked(useBCSCApiClient)
154+
const bifoldMock = jest.mocked(Bifold)
155+
const bcscCoreMock = jest.mocked(BcscCore)
156+
const tokensMock = jest.mocked(tokens)
157+
158+
const getFirstJwkMock = jest.fn().mockResolvedValue('jwk')
159+
bcscCoreMock.getAccount = jest.fn().mockResolvedValue({ clientID: 'client-id', issuer: 'issuer' } as any)
160+
tokensMock.getNotificationTokens = jest.fn().mockResolvedValue({ fcmDeviceToken: 'fcm', apnsToken: 'apns' })
161+
bcscCoreMock.createQuickLoginJWT = jest.fn().mockResolvedValue('test-jwt')
162+
163+
useApiMock.default.mockReturnValue({ jwks: { getFirstJwk: getFirstJwkMock } } as any)
164+
useClientMock.useBCSCApiClient.mockReturnValue({ tokens: { access_token: 'access-token' } } as any)
165+
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)
166+
167+
const hook = renderHook(() => useQuickLoginURL())
168+
const result = await hook.result.current({ client_ref_id: 'test', initiate_login_uri: 'https://example.com' })
169+
170+
expect(result).toEqual({ success: true, url: `https://example.com?login_hint=${encodeURIComponent('test-jwt')}` })
171+
172+
expect(tokensMock.getNotificationTokens).toHaveBeenCalledTimes(1)
173+
expect(bcscCoreMock.getAccount).toHaveBeenCalledTimes(1)
174+
expect(getFirstJwkMock).toHaveBeenCalledTimes(1)
175+
expect(bcscCoreMock.createQuickLoginJWT).toHaveBeenCalledTimes(1)
176+
expect(bcscCoreMock.createQuickLoginJWT).toHaveBeenCalledWith(
177+
'access-token',
178+
'client-id',
179+
'issuer',
180+
'test',
181+
'jwk',
182+
'fcm',
183+
'apns'
184+
)
185+
})
54186
})

app/src/bcsc-theme/features/account/Account.tsx

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,86 @@
11
import useApi from '@/bcsc-theme/api/hooks/useApi'
2-
import { UserInfoResponseData } from '@/bcsc-theme/api/hooks/useUserApi'
32
import SectionButton from '@/bcsc-theme/components/SectionButton'
43
import TabScreenWrapper from '@/bcsc-theme/components/TabScreenWrapper'
54
import { useBCSCApiClient } from '@/bcsc-theme/hooks/useBCSCApiClient'
6-
import { STUB_SERVICE_CLIENT, useQuickLoginURL } from '@/bcsc-theme/hooks/useQuickLoginUrl'
5+
import { useQuickLoginURL } from '@/bcsc-theme/hooks/useQuickLoginUrl'
76
import { BCSCRootStackParams, BCSCScreens } from '@/bcsc-theme/types/navigators'
87
import { BCState } from '@/store'
98
import { ThemedText, TOKENS, useServices, useStore, useTheme } from '@bifold/core'
109
import { useNavigation } from '@react-navigation/native'
1110
import { StackNavigationProp } from '@react-navigation/stack'
12-
import React, { useCallback, useEffect, useState } from 'react'
11+
import React, { useCallback, useEffect, useRef } from 'react'
1312
import { useTranslation } from 'react-i18next'
14-
import { ActivityIndicator, Linking, StyleSheet, View } from 'react-native'
13+
import { ActivityIndicator, AppState, Linking, StyleSheet, View } from 'react-native'
1514
import AccountField from './components/AccountField'
1615
import AccountPhoto from './components/AccountPhoto'
1716
import useDataLoader from '@/bcsc-theme/hooks/useDataLoader'
1817

1918
type AccountNavigationProp = StackNavigationProp<BCSCRootStackParams>
2019

20+
/**
21+
* Renders the account screen component, which displays user information and provides navigation to account-related actions.
22+
*
23+
* @returns {*} {JSX.Element} The account screen component.
24+
*/
2125
const Account: React.FC = () => {
2226
const { Spacing } = useTheme()
2327
const [store] = useStore<BCState>()
2428
const { user, metadata } = useApi()
2529
const client = useBCSCApiClient()
2630
const navigation = useNavigation<AccountNavigationProp>()
27-
const [loading, setLoading] = useState(true)
28-
const [userInfo, setUserInfo] = useState<UserInfoResponseData | null>(null)
29-
const [pictureUri, setPictureUri] = useState<string>()
3031
const { t } = useTranslation()
31-
3232
const [logger] = useServices([TOKENS.UTIL_LOGGER])
33+
const getQuickLoginURL = useQuickLoginURL()
34+
35+
const openedAccountWebview = useRef(false)
3336

3437
const { load: loadBcscServiceClient, data: bcscServiceClient } = useDataLoader(metadata.getBCSCClientMetadata, {
35-
onError: (error) => logger.error(`Error loading BCSC client metadata: ${error}`),
38+
onError: (error) => logger.error('Error loading BCSC client metadata', error as Error),
3639
})
3740

38-
// we can use the stub service client as a fallback, the hook will return null if no initiate_login_uri is present
39-
const [quickLoginUrl] = useQuickLoginURL(bcscServiceClient ?? STUB_SERVICE_CLIENT)
41+
/**
42+
* Fetches user metadata and picture URI.
43+
*
44+
* @return {*} {Promise<{ user: UserInfoResponseData; picture?: string }>} An object containing user metadata and optional picture URI.
45+
*/
46+
const fetchUserMetadata = useCallback(async () => {
47+
let pictureUri: string | undefined
48+
const userMetadata = await user.getUserInfo()
49+
50+
if (userMetadata.picture) {
51+
pictureUri = await user.getPicture(userMetadata.picture)
52+
}
53+
54+
return { user: userMetadata, picture: pictureUri }
55+
}, [user])
4056

57+
const {
58+
load: loadUserMeta,
59+
refresh: refreshUserMeta,
60+
...userMeta
61+
} = useDataLoader(fetchUserMetadata, {
62+
onError: (error) => logger.error('Error loading user info or picture', error as Error),
63+
})
64+
65+
// Initial data load
4166
useEffect(() => {
67+
loadUserMeta()
4268
loadBcscServiceClient()
43-
}, [loadBcscServiceClient])
69+
}, [loadUserMeta, loadBcscServiceClient])
4470

71+
// Refresh user data when returning to this screen from the BCSC Account webview
4572
useEffect(() => {
46-
const asyncEffect = async () => {
47-
try {
48-
setLoading(true)
49-
const userInfo = await user.getUserInfo()
50-
let picture = ''
51-
if (userInfo.picture) {
52-
picture = await user.getPicture(userInfo.picture)
53-
}
54-
setUserInfo(userInfo)
55-
setPictureUri(picture)
56-
} catch (error) {
57-
logger.error(`Error fetching user info, client metadata, or key: ${error}`)
58-
} finally {
59-
setLoading(false)
73+
const appListener = AppState.addEventListener('change', async (nextAppState) => {
74+
if (nextAppState === 'active' && openedAccountWebview.current) {
75+
logger.info('Returning from Account webview, refreshing user info...')
76+
openedAccountWebview.current = false
77+
refreshUserMeta()
6078
}
61-
}
79+
})
6280

63-
asyncEffect()
64-
}, [user, logger])
81+
// cleanup event listener on unmount
82+
return () => appListener.remove()
83+
}, [logger, refreshUserMeta])
6584

6685
const handleMyDevicesPress = useCallback(async () => {
6786
try {
@@ -77,13 +96,21 @@ const Account: React.FC = () => {
7796

7897
const handleAllAccountDetailsPress = useCallback(async () => {
7998
try {
80-
if (quickLoginUrl) {
81-
await Linking.openURL(quickLoginUrl)
99+
if (!bcscServiceClient) {
100+
// only generate quick login url if we have the bcsc service client metadata
101+
return
102+
}
103+
104+
const quickLoginResult = await getQuickLoginURL(bcscServiceClient)
105+
106+
if (quickLoginResult.success) {
107+
await Linking.openURL(quickLoginResult.url)
108+
openedAccountWebview.current = true
82109
}
83110
} catch (error) {
84111
logger.error(`Error opening All Account Details: ${error}`)
85112
}
86-
}, [quickLoginUrl, logger])
113+
}, [logger, getQuickLoginURL, bcscServiceClient])
87114

88115
const styles = StyleSheet.create({
89116
container: {
@@ -110,23 +137,24 @@ const Account: React.FC = () => {
110137

111138
return (
112139
<TabScreenWrapper>
113-
{loading && userInfo ? (
140+
{userMeta.isLoading ? (
114141
<ActivityIndicator size={'large'} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }} />
115142
) : (
116143
<View style={styles.container}>
117144
<View style={styles.photoAndNameContainer}>
118-
<AccountPhoto photoUri={pictureUri} />
145+
{/*TODO (MD): fallback for when this is undefined (silhouette) */}
146+
<AccountPhoto photoUri={userMeta.data?.picture} />
119147
<ThemedText variant={'headingTwo'} style={styles.name}>
120-
{userInfo?.family_name}, {userInfo?.given_name}
148+
{userMeta.data?.user?.family_name}, {userMeta.data?.user.given_name}
121149
</ThemedText>
122150
</View>
123151
<ThemedText
124152
style={styles.warning}
125153
>{`This cannot be used as photo ID, a driver's licence, or a health card.`}</ThemedText>
126-
<AccountField label={'App expiry date'} value={userInfo?.card_expiry ?? ''} />
127-
<AccountField label={'Account type'} value={userInfo?.card_type ?? ''} />
128-
<AccountField label={'Address'} value={userInfo?.address?.formatted ?? ''} />
129-
<AccountField label={'Date of birth'} value={userInfo?.birthdate ?? ''} />
154+
<AccountField label={'App expiry date'} value={userMeta.data?.user.card_expiry ?? ''} />
155+
<AccountField label={'Account type'} value={userMeta.data?.user.card_type ?? 'Non BC Services Card'} />
156+
<AccountField label={'Address'} value={userMeta.data?.user.address?.formatted ?? ''} />
157+
<AccountField label={'Date of birth'} value={userMeta.data?.user.birthdate ?? ''} />
130158
<AccountField label={'Email address'} value={store.bcsc.email ?? ''} />
131159

132160
<View style={styles.buttonsContainer}>

0 commit comments

Comments
 (0)