Skip to content

Commit d563d7e

Browse files
committed
Add useAsyncQuery and refactor hooks/tests
Introduce a shared useAsyncQuery hook to centralize stable-options β†’ skip β†’ memoize β†’ useAsync semantics and ensure consistent abort/skip behavior across hooks. Refactor useHasSecret and useSecretItem to use the new hook. Add deterministic test fixtures (buildTestItem/buildTestMetadata) and update unit tests to use them. Adjust error imports to prefer the public errors surface and remove legacy re-exports from src/internal/errors.ts. Bump package version to 6.0.0-rc.13. Files added: src/hooks/useAsyncQuery.ts, src/__tests__/__mocks__/fixtures.ts; files updated: multiple hooks, tests, and internal error helpers.
1 parent 60b2cf5 commit d563d7e

9 files changed

Lines changed: 112 additions & 115 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type {
2+
SensitiveInfoItem,
3+
StorageMetadata,
4+
} from '../../sensitive-info.nitro'
5+
6+
type MetadataOverrides = Partial<StorageMetadata>
7+
8+
/**
9+
* Builds a deterministic `StorageMetadata` for tests. Override any field by passing it in.
10+
*
11+
* @internal
12+
*/
13+
export const buildTestMetadata = (
14+
overrides: MetadataOverrides = {}
15+
): StorageMetadata => ({
16+
securityLevel: 'secureEnclave',
17+
backend: 'keychain',
18+
accessControl: 'secureEnclaveBiometry',
19+
timestamp: 1,
20+
...overrides,
21+
})
22+
23+
/**
24+
* Builds a deterministic `SensitiveInfoItem`. `metadata` overrides flow through to
25+
* {@link buildTestMetadata}; top-level fields override the item shell.
26+
*
27+
* @internal
28+
*/
29+
export const buildTestItem = (
30+
overrides: Partial<SensitiveInfoItem> & {
31+
readonly metadata?: MetadataOverrides
32+
} = {}
33+
): SensitiveInfoItem => {
34+
const { metadata, ...rest } = overrides
35+
return {
36+
key: 'token',
37+
service: 'auth',
38+
value: undefined,
39+
...rest,
40+
metadata: buildTestMetadata(metadata),
41+
}
42+
}

β€Žsrc/__tests__/hooks.useSecretItem.test.tsxβ€Ž

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react'
33
import { getItem } from '../core/storage'
44
import { HookError } from '../hooks/types'
55
import { useSecretItem } from '../hooks/useSecretItem'
6+
import { buildTestItem } from './__mocks__/fixtures'
67

78
jest.mock('../core/storage', () => ({
89
...jest.requireActual('../core/storage'),
@@ -17,17 +18,7 @@ describe('useSecretItem', () => {
1718
})
1819

1920
it('returns the fetched item', async () => {
20-
mockedGetItem.mockResolvedValueOnce({
21-
key: 'token',
22-
service: 'auth',
23-
value: 'value',
24-
metadata: {
25-
securityLevel: 'secureEnclave',
26-
backend: 'keychain',
27-
accessControl: 'secureEnclaveBiometry',
28-
timestamp: 1,
29-
},
30-
})
21+
mockedGetItem.mockResolvedValueOnce(buildTestItem({ value: 'value' }))
3122

3223
const { result } = renderHook(
3324
({ opts }: { opts: Parameters<typeof useSecretItem>[1] }) =>
@@ -82,16 +73,9 @@ describe('useSecretItem', () => {
8273
})
8374

8475
it('allows manual refetching', async () => {
85-
mockedGetItem.mockResolvedValueOnce(null).mockResolvedValueOnce({
86-
key: 'token',
87-
service: 'auth',
88-
metadata: {
89-
securityLevel: 'secureEnclave',
90-
backend: 'keychain',
91-
accessControl: 'secureEnclaveBiometry',
92-
timestamp: 2,
93-
},
94-
})
76+
mockedGetItem
77+
.mockResolvedValueOnce(null)
78+
.mockResolvedValueOnce(buildTestItem({ metadata: { timestamp: 2 } }))
9579

9680
const { result } = renderHook(
9781
({ opts }: { opts: Parameters<typeof useSecretItem>[1] }) =>

β€Žsrc/__tests__/hooks.useSecureStorage.test.tsxβ€Ž

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type UseSecureStorageOptions,
77
useSecureStorage,
88
} from '../hooks/useSecureStorage'
9+
import { buildTestItem, buildTestMetadata } from './__mocks__/fixtures'
910

1011
jest.mock('../core/storage', () => ({
1112
...jest.requireActual('../core/storage'),
@@ -22,45 +23,6 @@ const mockedClearService = clearService as jest.MockedFunction<
2223
typeof clearService
2324
>
2425

25-
type MetadataOverrides = {
26-
securityLevel?:
27-
| 'secureEnclave'
28-
| 'strongBox'
29-
| 'biometry'
30-
| 'deviceCredential'
31-
| 'software'
32-
backend?: 'keychain' | 'androidKeystore' | 'encryptedSharedPreferences'
33-
accessControl?:
34-
| 'secureEnclaveBiometry'
35-
| 'biometryCurrentSet'
36-
| 'biometryAny'
37-
| 'devicePasscode'
38-
| 'none'
39-
timestamp?: number
40-
}
41-
42-
function buildMetadata(overrides: MetadataOverrides = {}) {
43-
return {
44-
securityLevel: overrides.securityLevel ?? 'secureEnclave',
45-
backend: overrides.backend ?? 'keychain',
46-
accessControl: overrides.accessControl ?? 'secureEnclaveBiometry',
47-
timestamp: overrides.timestamp ?? Date.now(),
48-
}
49-
}
50-
51-
const buildItem = (
52-
overrides: MetadataOverrides & {
53-
key?: string
54-
service?: string
55-
value?: string
56-
} = {}
57-
) => ({
58-
key: overrides.key ?? 'token',
59-
service: overrides.service ?? 'auth',
60-
value: overrides.value,
61-
metadata: buildMetadata(overrides),
62-
})
63-
6426
describe('useSecureStorage', () => {
6527
beforeEach(() => {
6628
mockedGetAllItems.mockReset()
@@ -79,7 +41,9 @@ describe('useSecureStorage', () => {
7941
)
8042

8143
it('loads items on mount', async () => {
82-
mockedGetAllItems.mockResolvedValueOnce([buildItem({ value: 'secret' })])
44+
mockedGetAllItems.mockResolvedValueOnce([
45+
buildTestItem({ value: 'secret' }),
46+
])
8347

8448
const { result } = renderStorage({ service: 'auth', includeValues: true })
8549

@@ -118,7 +82,7 @@ describe('useSecureStorage', () => {
11882
it('exposes a refresh helper', async () => {
11983
mockedGetAllItems
12084
.mockResolvedValueOnce([])
121-
.mockResolvedValueOnce([buildItem({ key: 'next' })])
85+
.mockResolvedValueOnce([buildTestItem({ key: 'next' })])
12286

12387
const { result } = renderStorage({ service: 'auth' })
12488

@@ -135,7 +99,7 @@ describe('useSecureStorage', () => {
13599

136100
it('saves items and refreshes the list', async () => {
137101
mockedGetAllItems.mockResolvedValue([])
138-
mockedSetItem.mockResolvedValueOnce({ metadata: buildMetadata() })
102+
mockedSetItem.mockResolvedValueOnce({ metadata: buildTestMetadata() })
139103

140104
const { result } = renderStorage({ service: 'auth', includeValues: true })
141105

@@ -171,7 +135,7 @@ describe('useSecureStorage', () => {
171135

172136
it('removes items locally when delete succeeds', async () => {
173137
mockedGetAllItems.mockResolvedValueOnce([
174-
buildItem({ key: 'token', value: 'secret' }),
138+
buildTestItem({ key: 'token', value: 'secret' }),
175139
])
176140
mockedDeleteItem.mockResolvedValueOnce(true)
177141

@@ -208,7 +172,7 @@ describe('useSecureStorage', () => {
208172

209173
it('clears the service and resets local state', async () => {
210174
mockedGetAllItems.mockResolvedValueOnce([
211-
buildItem({ key: 'token', value: 'secret' }),
175+
buildTestItem({ key: 'token', value: 'secret' }),
212176
])
213177
mockedClearService.mockResolvedValueOnce()
214178

β€Žsrc/__tests__/internal.errors.test.tsβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getErrorMessage, isNotFoundError } from '../internal/errors'
1+
import { isNotFoundError } from '../errors'
2+
import { getErrorMessage } from '../internal/errors'
23

34
describe('internal/errors', () => {
45
describe('isNotFoundError', () => {

β€Žsrc/hooks/error-utils.tsβ€Ž

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import {
2-
getErrorMessage,
3-
isAuthenticationCanceledError as internalIsAuthenticationCanceledError,
4-
} from '../internal/errors'
1+
import { isAuthenticationCanceledError as internalIsAuthenticationCanceledError } from '../errors'
2+
import { getErrorMessage } from '../internal/errors'
53
import { HookError } from './types'
64

75
/**

β€Žsrc/hooks/useAsyncQuery.tsβ€Ž

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useCallback, useMemo } from 'react'
2+
import type { SensitiveInfoOptions } from '../sensitive-info.nitro'
3+
import useAsync, { type UseAsyncResult } from './useAsync'
4+
import useStableOptions from './useStableOptions'
5+
6+
/**
7+
* Shared "stable options β†’ strip skip β†’ memoize β†’ useAsync" recipe used by every parameterized
8+
* data-fetching hook. Keeps `useHasSecret`/`useSecretItem` (and friends) down to a single call
9+
* site and ensures the abort/skip semantics stay consistent across the public surface.
10+
*
11+
* @internal
12+
*/
13+
const useAsyncQuery = <T, O extends { readonly skip?: boolean }>(
14+
runner: (request: SensitiveInfoOptions) => Promise<T | null>,
15+
defaults: Required<Pick<O, 'skip'>> & Partial<Omit<O, 'skip'>>,
16+
operation: string,
17+
options: O | undefined,
18+
hint?: string
19+
): UseAsyncResult<T> => {
20+
const stable = useStableOptions<O>(defaults as Partial<O>, options)
21+
const { skip } = stable
22+
23+
const requestOptions = useMemo<SensitiveInfoOptions>(() => {
24+
const { skip: _skip, ...rest } = stable
25+
return rest as SensitiveInfoOptions
26+
}, [stable])
27+
28+
const run = useCallback(
29+
() => runner(requestOptions),
30+
[runner, requestOptions]
31+
)
32+
33+
return useAsync<T>(run, operation, { skip, hint })
34+
}
35+
36+
export default useAsyncQuery

β€Žsrc/hooks/useHasSecret.tsβ€Ž

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { useCallback, useMemo } from 'react'
21
import { hasItem } from '../core/storage'
32
import type { SensitiveInfoOptions } from '../sensitive-info.nitro'
43
import type { AsyncState } from './types'
5-
import useAsync from './useAsync'
6-
import useStableOptions from './useStableOptions'
4+
import useAsyncQuery from './useAsyncQuery'
75

86
export interface UseHasSecretOptions extends SensitiveInfoOptions {
97
/** Disable the automatic existence check while still exposing `refetch`. */
@@ -23,20 +21,11 @@ export function useHasSecret(
2321
key: string,
2422
options?: UseHasSecretOptions
2523
): UseHasSecretResult {
26-
const stable = useStableOptions<UseHasSecretOptions>(DEFAULTS, options)
27-
const { skip } = stable
28-
const requestOptions = useMemo<SensitiveInfoOptions>(() => {
29-
const { skip: _s, ...rest } = stable
30-
return rest
31-
}, [stable])
32-
33-
const run = useCallback(
34-
() => hasItem(key, requestOptions),
35-
[key, requestOptions]
24+
return useAsyncQuery<boolean, UseHasSecretOptions>(
25+
(request) => hasItem(key, request),
26+
DEFAULTS,
27+
'useHasSecret.evaluate',
28+
options,
29+
'Most commonly triggered by an invalid key/service combination.'
3630
)
37-
38-
return useAsync<boolean>(run, 'useHasSecret.evaluate', {
39-
hint: 'Most commonly triggered by an invalid key/service combination.',
40-
skip,
41-
})
4231
}

β€Žsrc/hooks/useSecretItem.tsβ€Ž

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import { useCallback, useMemo } from 'react'
21
import { getItem } from '../core/storage'
32
import type {
43
SensitiveInfoItem,
54
SensitiveInfoOptions,
65
} from '../sensitive-info.nitro'
76
import type { AsyncState } from './types'
8-
import useAsync from './useAsync'
9-
import useStableOptions from './useStableOptions'
7+
import useAsyncQuery from './useAsyncQuery'
108

119
export interface UseSecretItemOptions extends SensitiveInfoOptions {
1210
/** When `false`, skip decrypting the value and return metadata only. Defaults to `true`. */
@@ -32,20 +30,11 @@ export function useSecretItem(
3230
key: string,
3331
options?: UseSecretItemOptions
3432
): UseSecretItemResult {
35-
const stable = useStableOptions<UseSecretItemOptions>(DEFAULTS, options)
36-
const { skip } = stable
37-
const requestOptions = useMemo<SensitiveInfoOptions>(() => {
38-
const { skip: _s, ...rest } = stable
39-
return rest as SensitiveInfoOptions
40-
}, [stable])
41-
42-
const run = useCallback(
43-
() => getItem(key, requestOptions),
44-
[key, requestOptions]
33+
return useAsyncQuery<SensitiveInfoItem, UseSecretItemOptions>(
34+
(request) => getItem(key, request),
35+
DEFAULTS,
36+
'useSecretItem.fetch',
37+
options,
38+
'Verify that the key/service pair exists and that includeValue is allowed for the caller.'
4539
)
46-
47-
return useAsync<SensitiveInfoItem>(run, 'useSecretItem.fetch', {
48-
hint: 'Verify that the key/service pair exists and that includeValue is allowed for the caller.',
49-
skip,
50-
})
5140
}

β€Žsrc/internal/errors.tsβ€Ž

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
/**
2-
* Legacy re-export surface kept for backward compatibility. New code should import from
3-
* `react-native-sensitive-info/errors` (or `src/errors.ts`) directly so that typed classes and
4-
* predicates tree-shake cleanly.
2+
* Internal error helpers used by hook-side code paths. Public predicates and typed error classes
3+
* live in `src/errors.ts` (exposed via the `react-native-sensitive-info/errors` subpath); import
4+
* them from there directly.
55
*/
66

7-
export {
8-
isAuthenticationCanceledError,
9-
isNotFoundError,
10-
toSensitiveInfoError,
11-
} from '../errors'
12-
137
/**
148
* Extracts a human-readable message from arbitrary error values.
159
*/

0 commit comments

Comments
Β (0)