Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
10e5b21
feat: added getCardProcessForCardType utility function
MacDeluca Sep 11, 2025
e000a6d
feat: added useDebounce hook and wired up with service list
MacDeluca Sep 11, 2025
ff02efa
chore: updated tests for getCardProcessForCardType and updated enum refs
MacDeluca Sep 11, 2025
ed2eaba
chore: added tests for useDebounce custom hook
MacDeluca Sep 11, 2025
604be31
chore: wired up new ServiceDetailsScreen to navigation
MacDeluca Sep 11, 2025
04e7339
feat: added localizations for Services
MacDeluca Sep 11, 2025
6a49b2b
fix: issue with async request blocking navigation
MacDeluca Sep 11, 2025
a2716b0
chore: updated styling for search input
MacDeluca Sep 12, 2025
f095f39
chore: added localizations for search + sorted services alphabetically
MacDeluca Sep 12, 2025
945351b
chore: updated placeholder text colour
MacDeluca Sep 12, 2025
a571954
feat: search now sticky
MacDeluca Sep 12, 2025
e8b2377
chore: renamed ServiceDetailsScreen -> ServiceLoginScreen
MacDeluca Sep 12, 2025
f49bd33
feat: added new localizations
MacDeluca Sep 12, 2025
52b9528
feat: useClientQuickLoginUrl
MacDeluca Sep 12, 2025
ac064c3
chore: service -> serviceClients
MacDeluca Sep 12, 2025
567469a
chore: updated useClientQuickLoginUrl state
MacDeluca Sep 12, 2025
ec77390
chore: better search input styling
MacDeluca Sep 12, 2025
5f78d35
chore: normalizing standard language for ServiceClients
MacDeluca Sep 12, 2025
4ec4b24
chore: refactored useQuickLogin to be more generic to metadata clients
MacDeluca Sep 13, 2025
cc5caf7
fix: getAccount now working correctly with quick login url
MacDeluca Sep 15, 2025
7bb732f
feat: added useFilterServiceClients
MacDeluca Sep 15, 2025
f65b647
fix: merge conflicts with main resolved
MacDeluca Sep 15, 2025
5f1a891
chore: added tests for useFilterServiceClients
MacDeluca Sep 15, 2025
945bd36
chore: updated styling
MacDeluca Sep 15, 2025
f14495d
chore: updated localizations
MacDeluca Sep 15, 2025
e27eb6b
doc: updated useFilterServiceClients documentation
MacDeluca Sep 15, 2025
0c7ca91
chore: fixed linter issues
MacDeluca Sep 15, 2025
ebd662b
fix: merge conflicts with main resolved
MacDeluca Sep 15, 2025
bf0fa14
fix: broken test durrrrr
MacDeluca Sep 15, 2025
13da26b
fix: loading data correctly using data loaders
MacDeluca Sep 16, 2025
ef4a8b9
Merge branch 'main' into md_bcsc_2563_services_catalogue
al-rosenthal Sep 16, 2025
cad759d
chore: refactored useQuickLoginURL to return error message
MacDeluca Sep 16, 2025
a34a5b2
fix: added localizations for error alert
MacDeluca Sep 16, 2025
5dbbf8c
Merge branch 'md_bcsc_2563_services_catalogue' of https://github.com/…
MacDeluca Sep 16, 2025
ca70d49
fix: scrollview added to service login screen
MacDeluca Sep 16, 2025
3589cf7
fix: styling issue
MacDeluca Sep 16, 2025
dd6e156
chore: linter
MacDeluca Sep 16, 2025
aa1d4a1
chore: updated test
MacDeluca Sep 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions app/__tests__/bcsc-theme/hooks/useFilterServiceClients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import * as Bifold from '@bifold/core'
import { useFilterServiceClients } from '@/bcsc-theme/features/services/hooks/useFilterServiceClients'
import { renderHook, waitFor } from '@testing-library/react-native'
import * as useApi from '@/bcsc-theme/api/hooks/useApi'
import * as navigation from '@react-navigation/native'
import { ClientMetadata } from '@/bcsc-theme/api/hooks/useMetadataApi'
import { BCSCCardProcess } from '@/bcsc-theme/types/cards'

jest.mock('@bifold/core')
jest.mock('@/bcsc-theme/api/hooks/useApi')
jest.mock('@react-navigation/native')

const mockServiceClientA: ClientMetadata = {
client_ref_id: 'test-client-id-a',
client_name: 'TEST CLIENT ALPHA',
client_uri: 'https://test.client.a',
application_type: 'web',
claims_description: 'claims',
suppress_confirmation_info: false,
suppress_bookmark_prompt: false,
allowed_identification_processes: [BCSCCardProcess.BCSC],
bc_address: true,
}

const mockServiceClientB: ClientMetadata = {
client_ref_id: 'test-client-id-b',
client_name: 'TEST CLIENT BETA',
client_uri: 'https://test.client.b',
application_type: 'web',
claims_description: 'claims',
suppress_confirmation_info: false,
suppress_bookmark_prompt: false,
allowed_identification_processes: [BCSCCardProcess.NonBCSC],
bc_address: false,
}

describe('useFilterServiceClients', () => {
beforeEach(() => {
jest.resetAllMocks()
})

describe('no filters', () => {
it('should return all service clients when no filters are applied', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({}))

await waitFor(() => {
expect(hook.result.current).toHaveLength(2)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
expect(hook.result.current[1].client_ref_id).toBe(mockServiceClientB.client_ref_id)
})
})
})

describe('card process filter', () => {
it('should filter out service clients by card process', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ cardProcessFilter: BCSCCardProcess.BCSC }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})
})

describe('BC address filter', () => {
it('should filter out non BC service clients', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ requireBCAddressFilter: true }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})
})

describe('partial name filter', () => {
it('should filter service clients by partial name match', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ partialNameFilter: 'ALPHA' }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})

it('should filter service clients by partial name match (case insensitive)', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ partialNameFilter: 'Alpha' }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})

it('should filter service clients by partial name match multiple words', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ partialNameFilter: 'client alpha' }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})

it('should return no service clients when no match', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ partialNameFilter: 'badbadbad' }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(0)
})
})

it('should return no service clients when partial name words are not contiguous', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() => useFilterServiceClients({ partialNameFilter: 'TEST ALPHA' }))

await waitFor(() => {
expect(hook.result.current).toHaveLength(0)
})
})
})

describe('combined filters', () => {
it('should filter service clients by multiple criteria', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() =>
useFilterServiceClients({
cardProcessFilter: BCSCCardProcess.BCSC,
requireBCAddressFilter: true,
partialNameFilter: 'ALPHA',
})
)

await waitFor(() => {
expect(hook.result.current).toHaveLength(1)
expect(hook.result.current[0].client_ref_id).toBe(mockServiceClientA.client_ref_id)
})
})

it('should filter service clients by multiple criteria and return zero when one filter misses', async () => {
const bifoldMock = jest.mocked(Bifold)
const useApiMock = jest.mocked(useApi)
const navigationMock = jest.mocked(navigation)

useApiMock.default.mockReturnValue({
metadata: {
getClientMetadata: jest.fn().mockResolvedValue([mockServiceClientA, mockServiceClientB]),
},
} as any)
navigationMock.useNavigation.mockReturnValue({ navigation: jest.fn() })
bifoldMock.useServices.mockReturnValue([{ error: jest.fn() }] as any)

const hook = renderHook(() =>
useFilterServiceClients({
cardProcessFilter: BCSCCardProcess.NonBCSC,
requireBCAddressFilter: true,
partialNameFilter: 'ALPHA',
})
)

await waitFor(() => {
expect(hook.result.current).toHaveLength(0)
})
})
})
})
42 changes: 42 additions & 0 deletions app/__tests__/bcsc-theme/utils/card-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BCSCCardProcess, BCSCCardType } from '@/bcsc-theme/types/cards'
import { getCardProcessForCardType } from '@/bcsc-theme/utils/card-utils'

describe('Card Utils', () => {
describe('getCardProcessForCardType', () => {
it('should return BCSC for Combined card type', () => {
expect(getCardProcessForCardType(BCSCCardType.Combined)).toBe(BCSCCardProcess.BCSC)
})

it('should return BCSC for Photo card type', () => {
expect(getCardProcessForCardType(BCSCCardType.Photo)).toBe(BCSCCardProcess.BCSC)
})

it('should return BCSCNonPhoto for NonPhoto card type', () => {
expect(getCardProcessForCardType(BCSCCardType.NonPhoto)).toBe(BCSCCardProcess.BCSCNonPhoto)
})

it('should return NonBCSC for Other card type', () => {
expect(getCardProcessForCardType(BCSCCardType.Other)).toBe(BCSCCardProcess.NonBCSC)
})

it('should throw an error for None card type', () => {
expect(() => getCardProcessForCardType(BCSCCardType.None)).toThrow(`Invalid card type: ${BCSCCardType.None}}`)
})

it('should throw an error for unknown card type', () => {
expect(() => getCardProcessForCardType(99 as any)).toThrow('Unknown card type: 99')
})

it('should support all BCSCCardType values', () => {
const cardTypes = Object.values(BCSCCardType)
cardTypes.forEach((cardType) => {
if (cardType === BCSCCardType.None) {
expect(() => getCardProcessForCardType(cardType)).toThrow()
} else {
const process = getCardProcessForCardType(cardType)
expect(Object.values(BCSCCardProcess)).toContain(process)
}
})
})
})
})
71 changes: 71 additions & 0 deletions app/__tests__/hooks/useDebounce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useDebounce } from '@/hooks/useDebounce'
import { act, renderHook } from '@testing-library/react-native'

describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})

it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500))
expect(result.current).toBe('hello')
})

it('should not update value before delay', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'a', delay: 500 },
})

rerender({ value: 'b', delay: 500 })
// advance less than delay
act(() => {
jest.advanceTimersByTime(400)
})

expect(result.current).toBe('a') // still old value
})

it('should update value after delay', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'a', delay: 500 },
})

rerender({ value: 'b', delay: 500 })
act(() => {
jest.advanceTimersByTime(500)
})

expect(result.current).toBe('b') // updated after debounce delay
})

it('should reset timer if value changes before delay', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'a', delay: 500 },
})

rerender({ value: 'b', delay: 500 })

act(() => {
jest.advanceTimersByTime(300) // not enough yet
})

rerender({ value: 'c', delay: 500 })

act(() => {
jest.advanceTimersByTime(300) // still not enough for new value
})

expect(result.current).toBe('a') // still initial

act(() => {
jest.advanceTimersByTime(200) // complete the 500ms for 'c'
})

expect(result.current).toBe('c')
})
})
Loading
Loading