@smileid/usesmileid 12.0.1 → 12.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -0,0 +1,182 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * User-detail and consent bindings delivered via the JWT auth token.
5
+ *
6
+ * The backend embeds this object under the top-level `payload` claim
7
+ * (JWT payload, base64url-decoded middle segment) when the partner
8
+ * binds user details and/or consent into the token on their backend.
9
+ * User-detail values in the claim are opaque vault references — only
10
+ * key PRESENCE is meaningful client-side. Consent subfields are
11
+ * plaintext. `country` / `id_type` / `id_number` / `callback_url` may
12
+ * also appear in the claim but are deliberately ignored.
13
+ *
14
+ * Every field is optional and every field tolerates a wrong wire type
15
+ * by degrading to absent — a malformed claim never rejects the token.
16
+ *
17
+ * Field names are a deliberate cross-SDK shared contract that
18
+ * intentionally omits the `UseSmileID` prefix. Internal — not
19
+ * re-exported from the package entrypoint (`src/index.ts`).
20
+ */
21
+
22
+ const tokenConsentClaimSchema = z.object({
23
+ /** Only a literal `true` counts — `false` or a non-boolean is never trusted as a binding. */
24
+ granted: z.literal(true).optional().catch(undefined),
25
+ granted_at: z.string().optional().catch(undefined),
26
+ notice_language: z.string().optional().catch(undefined),
27
+ notice_privacy_policy_url: z.string().optional().catch(undefined),
28
+ });
29
+
30
+ /**
31
+ * Tolerant wire schema for the `payload` claim. Unknown keys are
32
+ * stripped; a wrong-typed field degrades to `undefined` (per-field
33
+ * `.catch`) instead of failing the whole claim.
34
+ */
35
+ export const tokenPayloadSchema = z.object({
36
+ given_names: z.string().optional().catch(undefined),
37
+ last_name: z.string().optional().catch(undefined),
38
+ email: z.string().optional().catch(undefined),
39
+ phone_number: z.string().optional().catch(undefined),
40
+ /** Non-object / array consent values degrade to absent. */
41
+ consent: tokenConsentClaimSchema.optional().catch(undefined),
42
+ });
43
+
44
+ /**
45
+ * Consent subfields bound into the token. Token consent always wins:
46
+ * a complete binding (see {@link isTokenConsentComplete}) supersedes
47
+ * every local consent source, and a partial binding is a build-time
48
+ * validation error naming the missing subfields (see
49
+ * {@link tokenConsentMissingSubfields}).
50
+ */
51
+ export type TokenConsent = {
52
+ /** Present (and `true`) only when the claim carried a literal `granted: true`. */
53
+ readonly granted?: boolean;
54
+ /** ISO-8601 timestamp from the claim's `granted_at`. */
55
+ readonly grantedAt?: string;
56
+ /** Language code from the claim's `notice_language`. */
57
+ readonly noticeLanguage?: string;
58
+ /** URL from the claim's `notice_privacy_policy_url`. */
59
+ readonly noticePrivacyPolicyUrl?: string;
60
+ };
61
+
62
+ /**
63
+ * Presence flags for the identity fields the token binds, parsed from
64
+ * the `payload` claim. A flag is `true` iff the claim carries the key
65
+ * with a non-empty string value (the value itself is an opaque vault
66
+ * reference — the server injects the real value at submission).
67
+ */
68
+ export type TokenPayload = {
69
+ readonly hasGivenNames: boolean;
70
+ readonly hasLastName: boolean;
71
+ readonly hasEmail: boolean;
72
+ readonly hasPhoneNumber: boolean;
73
+ readonly consent?: TokenConsent;
74
+ };
75
+
76
+ /**
77
+ * Parse a raw `payload` claim value into a `TokenPayload`, or
78
+ * `undefined` when the claim is absent or not a JSON object. Mirrors
79
+ * the `sdk_config` degradation semantics: never throws, tolerates
80
+ * unknown keys, and degrades wrong-typed fields to absent.
81
+ */
82
+ export function parseTokenPayload(rawClaim: unknown): TokenPayload | undefined {
83
+ if (
84
+ rawClaim === null ||
85
+ typeof rawClaim !== "object" ||
86
+ Array.isArray(rawClaim)
87
+ ) {
88
+ return undefined;
89
+ }
90
+ const parseResult = tokenPayloadSchema.safeParse(rawClaim);
91
+ if (!parseResult.success) {
92
+ // Unreachable in practice (every field carries a `.catch`), but
93
+ // degrade gracefully to preserve the never-throws contract.
94
+ return undefined;
95
+ }
96
+ const claim = parseResult.data;
97
+ const consent = tokenConsentFromClaim(claim.consent);
98
+ return {
99
+ hasGivenNames: isPresent(claim.given_names),
100
+ hasLastName: isPresent(claim.last_name),
101
+ hasEmail: isPresent(claim.email),
102
+ hasPhoneNumber: isPresent(claim.phone_number),
103
+ ...(consent !== undefined ? { consent } : {}),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Token consent is COMPLETE when `granted` is `true` and every other
109
+ * subfield is non-blank. A complete binding — granted with every
110
+ * notice subfield non-blank — satisfies the consent requirement
111
+ * without a local source. Token consent always wins, so a partial
112
+ * binding is a build-time validation error.
113
+ */
114
+ export function isTokenConsentComplete(
115
+ consent: TokenConsent | undefined,
116
+ ): boolean {
117
+ return (
118
+ consent !== undefined && tokenConsentMissingSubfields(consent).length === 0
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Wire-key names of the consent subfields the binding is missing. A
124
+ * token that carries a consent object must carry all four subfields —
125
+ * a non-empty list is a build-time validation error naming exactly
126
+ * what the token must add.
127
+ */
128
+ export function tokenConsentMissingSubfields(
129
+ consent: TokenConsent,
130
+ ): readonly string[] {
131
+ const missing: string[] = [];
132
+ if (consent.granted !== true) missing.push("granted");
133
+ if (!isNonBlank(consent.grantedAt)) missing.push("granted_at");
134
+ if (!isNonBlank(consent.noticeLanguage)) missing.push("notice_language");
135
+ if (!isNonBlank(consent.noticePrivacyPolicyUrl)) {
136
+ missing.push("notice_privacy_policy_url");
137
+ }
138
+ return missing;
139
+ }
140
+
141
+ /**
142
+ * Whether the token binds every user-detail field the SDK would
143
+ * otherwise require from the builder: `given_names` AND `last_name`
144
+ * AND at least one of `email` / `phone_number`. Only then may
145
+ * `builder.userDetails` be omitted entirely.
146
+ */
147
+ export function bindsRequiredUserDetails(
148
+ payload: TokenPayload | undefined,
149
+ ): boolean {
150
+ if (payload === undefined) return false;
151
+ return (
152
+ payload.hasGivenNames &&
153
+ payload.hasLastName &&
154
+ (payload.hasEmail || payload.hasPhoneNumber)
155
+ );
156
+ }
157
+
158
+ function tokenConsentFromClaim(
159
+ claim: z.infer<typeof tokenConsentClaimSchema> | undefined,
160
+ ): TokenConsent | undefined {
161
+ if (claim === undefined) return undefined;
162
+ const consent: TokenConsent = {
163
+ ...(claim.granted === true ? { granted: true } : {}),
164
+ ...(claim.granted_at !== undefined ? { grantedAt: claim.granted_at } : {}),
165
+ ...(claim.notice_language !== undefined
166
+ ? { noticeLanguage: claim.notice_language }
167
+ : {}),
168
+ ...(claim.notice_privacy_policy_url !== undefined
169
+ ? { noticePrivacyPolicyUrl: claim.notice_privacy_policy_url }
170
+ : {}),
171
+ };
172
+ // An empty consent object binds nothing — treat it as absent.
173
+ return Object.keys(consent).length > 0 ? consent : undefined;
174
+ }
175
+
176
+ function isPresent(value: string | undefined): boolean {
177
+ return value !== undefined && value.length > 0;
178
+ }
179
+
180
+ function isNonBlank(value: string | undefined): boolean {
181
+ return value !== undefined && value.trim().length > 0;
182
+ }
@@ -0,0 +1,84 @@
1
+ import type { UserDetails } from "../../core/models/params";
2
+ import type { TokenPayload } from "./token_payload";
3
+
4
+ /**
5
+ * Wire carrier for the `user_details` multipart part.
6
+ *
7
+ * Unlike `UserDetails` — the partner-facing input model whose names are
8
+ * required — every field here is optional so the part can carry only the
9
+ * fields the token does **not** already bind. Token-bound fields are dropped
10
+ * at assembly (`userDetailsPayloadFrom`): the server injects the bound values
11
+ * and they take precedence over anything the client sends. When nothing
12
+ * remains, assembly omits the part entirely (`isUserDetailsPayloadEmpty`).
13
+ *
14
+ * Type and helper names are a deliberate cross-SDK shared contract and
15
+ * intentionally omit the `UseSmileID` prefix (like `TokenPayload`).
16
+ */
17
+ export type UserDetailsPayload = {
18
+ readonly givenNames?: string;
19
+ readonly lastName?: string;
20
+ readonly email?: string;
21
+ readonly phoneNumber?: string;
22
+ };
23
+
24
+ /**
25
+ * Build the payload from builder-supplied details, dropping every field the
26
+ * token binds. With no token binding this reproduces `UserDetails`' wire
27
+ * encoding unchanged.
28
+ */
29
+ export function userDetailsPayloadFrom(
30
+ userDetails: UserDetails,
31
+ droppingFieldsBoundBy: TokenPayload | undefined,
32
+ ): UserDetailsPayload {
33
+ return {
34
+ ...(droppingFieldsBoundBy?.hasGivenNames
35
+ ? {}
36
+ : { givenNames: userDetails.givenNames }),
37
+ ...(droppingFieldsBoundBy?.hasLastName
38
+ ? {}
39
+ : { lastName: userDetails.lastName }),
40
+ ...(droppingFieldsBoundBy?.hasEmail || userDetails.email === undefined
41
+ ? {}
42
+ : { email: userDetails.email }),
43
+ ...(droppingFieldsBoundBy?.hasPhoneNumber ||
44
+ userDetails.phoneNumber === undefined
45
+ ? {}
46
+ : { phoneNumber: userDetails.phoneNumber }),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * True when `userDetailsPayloadToWire` would produce an empty object — the
52
+ * caller omits the part instead of sending `{}`. Blank contacts count as
53
+ * empty, mirroring the wire encoding that trims and drops them.
54
+ */
55
+ export function isUserDetailsPayloadEmpty(
56
+ payload: UserDetailsPayload,
57
+ ): boolean {
58
+ return Object.keys(userDetailsPayloadToWire(payload)).length === 0;
59
+ }
60
+
61
+ /**
62
+ * Wire shape: snake_case keys. Names are emitted as supplied when non-blank;
63
+ * contact fields are trimmed and omitted when blank.
64
+ */
65
+ export function userDetailsPayloadToWire(
66
+ payload: UserDetailsPayload,
67
+ ): Record<string, string> {
68
+ const wire: Record<string, string> = {};
69
+ if (payload.givenNames !== undefined && payload.givenNames.trim() !== "") {
70
+ wire["given_names"] = payload.givenNames;
71
+ }
72
+ if (payload.lastName !== undefined && payload.lastName.trim() !== "") {
73
+ wire["last_name"] = payload.lastName;
74
+ }
75
+ const trimmedEmail = payload.email?.trim();
76
+ if (trimmedEmail) {
77
+ wire["email"] = trimmedEmail;
78
+ }
79
+ const trimmedPhone = payload.phoneNumber?.trim();
80
+ if (trimmedPhone) {
81
+ wire["phone_number"] = trimmedPhone;
82
+ }
83
+ return wire;
84
+ }
@@ -34,7 +34,7 @@ const CONSENT: ConsentInformation = Object.freeze({
34
34
  });
35
35
 
36
36
  const METADATA = new UseSmileIDMetadata({
37
- sdkVersion: "12.0.1",
37
+ sdkVersion: "12.0.2",
38
38
  sdk: "react-native-expo",
39
39
  deviceOs: "",
40
40
  deviceModel: "",
@@ -0,0 +1,366 @@
1
+ import { describe, expect, it, beforeEach, vi } from "vitest";
2
+
3
+ // `vi.mock` is hoisted above the imports, so anything its factory closes over
4
+ // must be created with `vi.hoisted` or it is still in the TDZ when the factory
5
+ // runs.
6
+ const { compress } = vi.hoisted(() => ({ compress: vi.fn() }));
7
+
8
+ vi.mock("axios", () => ({
9
+ __esModule: true,
10
+ default: { create: vi.fn(), isAxiosError: () => false },
11
+ isAxiosError: () => false,
12
+ }));
13
+
14
+ // The compression runner touches `expo-file-system` and the native re-encoder;
15
+ // the repository only cares that it produces (or fails to produce) size hints.
16
+ vi.mock("../../compression/payload_compression_runner", () => ({
17
+ PayloadCompressionRunner: { compress },
18
+ }));
19
+
20
+ import { UseSmileIDRepositoryImpl } from "../use_smile_id_repository_impl";
21
+ import {
22
+ RetryStrategy,
23
+ type RetryConfig,
24
+ } from "../../../core/models/network_config";
25
+ import { JobType } from "../../../core/models/job_type";
26
+ import { UseSmileIDMetadata } from "../../../metadata/use_smile_id_metadata";
27
+ import { UseSmileIDValidationException } from "../../../core/exception/use_smile_id_validation_exception";
28
+ import { UseSmileIDNetworkException } from "../../../core/exception/use_smile_id_network_exception";
29
+ import type { JobRequest } from "../../model/job_request";
30
+ import type { JobSubmissionResponse } from "../../model/job_submission_response";
31
+ import type { ConsentInformation } from "../../../core/models/params";
32
+
33
+ /**
34
+ * Repository dispatch + pre-flight compression tests: every job type must
35
+ * reach its matching API method, the compression sizes must land on the
36
+ * submitted metadata, and a compression failure must never block the
37
+ * submission. The BVN short-circuit lives in `use_smile_id_repository.test.ts`.
38
+ */
39
+
40
+ const NO_RETRY: RetryConfig = Object.freeze({
41
+ maxAttempts: 1,
42
+ strategy: RetryStrategy.none,
43
+ initialDelay: 0,
44
+ multiplier: 1,
45
+ maxDelay: 0,
46
+ jitter: 0,
47
+ fixedDelay: 0,
48
+ });
49
+
50
+ const CONSENT: ConsentInformation = Object.freeze({
51
+ granted: true,
52
+ grantedAt: "2026-04-30T00:00:00.000Z",
53
+ noticeLanguage: "EN",
54
+ noticePrivacyPolicyUrl: "https://example.test/p",
55
+ });
56
+
57
+ const METADATA = new UseSmileIDMetadata({
58
+ sdkVersion: "12.0.2",
59
+ sdk: "react-native-expo",
60
+ deviceOs: "",
61
+ deviceModel: "",
62
+ appName: "",
63
+ sessionId: "test-session-id",
64
+ clientIp: "",
65
+ networkConnection: "",
66
+ carrierInfo: "",
67
+ timezone: "",
68
+ locale: "",
69
+ screenResolution: "",
70
+ memoryInfoMb: 0,
71
+ deviceOrientation: "",
72
+ systemArchitecture: "",
73
+ numberOfCameras: 0,
74
+ hostApplication: "",
75
+ securityPolicyVersion: "12.0.0",
76
+ sdkConfigSource: "default",
77
+ imageWidth: 0,
78
+ imageHeight: 0,
79
+ livenessImageWidth: 0,
80
+ livenessImageHeight: 0,
81
+ });
82
+
83
+ const RESPONSE: JobSubmissionResponse = {
84
+ jobId: "job-1",
85
+ userId: "user-1",
86
+ status: "approved",
87
+ message: "ok",
88
+ };
89
+
90
+ const USER_DETAILS = {
91
+ givenNames: "A",
92
+ lastName: "B",
93
+ email: "a@b.test",
94
+ } as const;
95
+
96
+ type StubApi = Record<string, ReturnType<typeof vi.fn>>;
97
+
98
+ function makeApi(): StubApi {
99
+ return {
100
+ enhancedKyc: vi.fn(async () => RESPONSE),
101
+ biometricKyc: vi.fn(async () => RESPONSE),
102
+ smartSelfieEnrollment: vi.fn(async () => RESPONSE),
103
+ smartSelfieAuthentication: vi.fn(async () => RESPONSE),
104
+ documentVerification: vi.fn(async () => RESPONSE),
105
+ enhancedDocumentVerification: vi.fn(async () => RESPONSE),
106
+ };
107
+ }
108
+
109
+ function makeRepository(api: StubApi, retry: RetryConfig = NO_RETRY) {
110
+ return new UseSmileIDRepositoryImpl(
111
+ api as unknown as ConstructorParameters<typeof UseSmileIDRepositoryImpl>[0],
112
+ retry,
113
+ );
114
+ }
115
+
116
+ const selfieEnrollment: JobRequest = {
117
+ jobType: JobType.smartSelfieEnrollment,
118
+ selfieImage: "file:///selfie.jpg",
119
+ livenessImages: ["file:///liveness-1.jpg"],
120
+ userDetails: USER_DETAILS,
121
+ enhanced: false,
122
+ expectedLivenessImageCount: 1,
123
+ consent: CONSENT,
124
+ metadata: METADATA,
125
+ };
126
+
127
+ const selfieAuthentication: JobRequest = {
128
+ jobType: JobType.smartSelfieAuthentication,
129
+ selfieImage: "file:///selfie.jpg",
130
+ livenessImages: [],
131
+ userDetails: USER_DETAILS,
132
+ userId: "user-1",
133
+ enhanced: true,
134
+ expectedLivenessImageCount: 0,
135
+ consent: CONSENT,
136
+ metadata: METADATA,
137
+ };
138
+
139
+ const biometricKyc: JobRequest = {
140
+ jobType: JobType.biometricKyc,
141
+ selfieImage: "file:///selfie.jpg",
142
+ livenessImages: [],
143
+ params: { idType: "NIN", idNumber: "1", country: "NG" },
144
+ userDetails: USER_DETAILS,
145
+ expectedLivenessImageCount: 0,
146
+ consent: CONSENT,
147
+ metadata: METADATA,
148
+ };
149
+
150
+ const enhancedKyc: JobRequest = {
151
+ jobType: JobType.enhancedKyc,
152
+ params: { idType: "NIN", idNumber: "1", country: "NG" },
153
+ userDetails: USER_DETAILS,
154
+ consent: CONSENT,
155
+ metadata: METADATA,
156
+ };
157
+
158
+ const documentVerification: JobRequest = {
159
+ jobType: JobType.documentVerification,
160
+ selfieImage: "file:///selfie.jpg",
161
+ livenessImages: [],
162
+ frontDocument: "file:///front.jpg",
163
+ backDocument: "file:///back.jpg",
164
+ country: "NG",
165
+ userDetails: USER_DETAILS,
166
+ expectedLivenessImageCount: 0,
167
+ consent: CONSENT,
168
+ metadata: METADATA,
169
+ };
170
+
171
+ const enhancedDocumentVerification: JobRequest = {
172
+ jobType: JobType.enhancedDocumentVerification,
173
+ selfieImage: "file:///selfie.jpg",
174
+ livenessImages: [],
175
+ frontDocument: "file:///front.jpg",
176
+ country: "NG",
177
+ idType: "NATIONAL_ID",
178
+ userDetails: USER_DETAILS,
179
+ expectedLivenessImageCount: 0,
180
+ consent: CONSENT,
181
+ metadata: METADATA,
182
+ };
183
+
184
+ function metadataNames(request: unknown): readonly string[] {
185
+ const { metadata } = request as { metadata: UseSmileIDMetadata };
186
+ return metadata.toEntries().map((entry) => entry.name);
187
+ }
188
+
189
+ function metadataValue(request: unknown, name: string): string | undefined {
190
+ const { metadata } = request as { metadata: UseSmileIDMetadata };
191
+ return metadata.toEntries().find((entry) => entry.name === name)?.value;
192
+ }
193
+
194
+ describe("UseSmileIDRepositoryImpl — dispatch", () => {
195
+ beforeEach(() => {
196
+ compress.mockReset().mockResolvedValue({});
197
+ });
198
+
199
+ const cases: ReadonlyArray<readonly [string, JobRequest, string]> = [
200
+ ["smart selfie enrollment", selfieEnrollment, "smartSelfieEnrollment"],
201
+ [
202
+ "smart selfie authentication",
203
+ selfieAuthentication,
204
+ "smartSelfieAuthentication",
205
+ ],
206
+ ["enhanced KYC", enhancedKyc, "enhancedKyc"],
207
+ ["biometric KYC", biometricKyc, "biometricKyc"],
208
+ ["document verification", documentVerification, "documentVerification"],
209
+ [
210
+ "enhanced document verification",
211
+ enhancedDocumentVerification,
212
+ "enhancedDocumentVerification",
213
+ ],
214
+ ];
215
+
216
+ for (const [label, request, method] of cases) {
217
+ it(`routes ${label} to api.${method}`, async () => {
218
+ const api = makeApi();
219
+ const result = await makeRepository(api).submit(request);
220
+
221
+ expect(result.status).toBe("success");
222
+ expect(api[method]).toHaveBeenCalledTimes(1);
223
+ for (const [, , other] of cases) {
224
+ if (other !== method) expect(api[other]).not.toHaveBeenCalled();
225
+ }
226
+ });
227
+ }
228
+ });
229
+
230
+ describe("UseSmileIDRepositoryImpl — pre-flight compression", () => {
231
+ beforeEach(() => {
232
+ compress.mockReset().mockResolvedValue({});
233
+ });
234
+
235
+ it("compresses the selfie bundle for selfie job types", async () => {
236
+ const api = makeApi();
237
+ await makeRepository(api).submit(selfieEnrollment);
238
+
239
+ expect(compress).toHaveBeenCalledTimes(1);
240
+ const args = compress.mock.calls[0]?.[0] as Record<string, unknown>;
241
+ expect(args.selfieImage).toBe("file:///selfie.jpg");
242
+ expect(args.livenessImages).toEqual(["file:///liveness-1.jpg"]);
243
+ expect(args.frontDocument).toBeUndefined();
244
+ expect(args.metadataBytes).toBeGreaterThan(0);
245
+ });
246
+
247
+ it("includes both document sides for document job types", async () => {
248
+ const api = makeApi();
249
+ await makeRepository(api).submit(documentVerification);
250
+
251
+ const args = compress.mock.calls[0]?.[0] as Record<string, unknown>;
252
+ expect(args.frontDocument).toBe("file:///front.jpg");
253
+ expect(args.backDocument).toBe("file:///back.jpg");
254
+ });
255
+
256
+ it("skips compression entirely for image-less job types", async () => {
257
+ const api = makeApi();
258
+ await makeRepository(api).submit(enhancedKyc);
259
+
260
+ expect(compress).not.toHaveBeenCalled();
261
+ expect(metadataNames(api.enhancedKyc.mock.calls[0]?.[0])).not.toContain(
262
+ "selfie_original_size",
263
+ );
264
+ });
265
+
266
+ it("stamps every measured size onto the submitted metadata", async () => {
267
+ compress.mockResolvedValue({
268
+ selfieOriginalMb: 1.5,
269
+ selfieFinalMb: 0.5,
270
+ frontOriginalMb: 2,
271
+ frontFinalMb: 1,
272
+ backOriginalMb: 3,
273
+ backFinalMb: 1.25,
274
+ });
275
+ const api = makeApi();
276
+ await makeRepository(api).submit(documentVerification);
277
+
278
+ const submitted = api.documentVerification.mock.calls[0]?.[0];
279
+ expect(metadataValue(submitted, "selfie_original_size")).toBe("1.5");
280
+ expect(metadataValue(submitted, "selfie_final_compressed_size")).toBe(
281
+ "0.5",
282
+ );
283
+ expect(metadataValue(submitted, "document_front_original_size")).toBe("2");
284
+ expect(
285
+ metadataValue(submitted, "document_front_final_compressed_size"),
286
+ ).toBe("1");
287
+ expect(metadataValue(submitted, "document_back_original_size")).toBe("3");
288
+ expect(
289
+ metadataValue(submitted, "document_back_final_compressed_size"),
290
+ ).toBe("1.25");
291
+ // The caller's metadata instance is never mutated.
292
+ expect(METADATA.toEntries().map((entry) => entry.name)).not.toContain(
293
+ "selfie_original_size",
294
+ );
295
+ });
296
+
297
+ it("drops absent and zero sizes rather than stamping them", async () => {
298
+ compress.mockResolvedValue({
299
+ selfieOriginalMb: 1.5,
300
+ selfieFinalMb: 0,
301
+ frontOriginalMb: undefined,
302
+ });
303
+ const api = makeApi();
304
+ await makeRepository(api).submit(documentVerification);
305
+
306
+ const submitted = api.documentVerification.mock.calls[0]?.[0];
307
+ const stamped = metadataNames(submitted);
308
+ expect(stamped).toContain("selfie_original_size");
309
+ expect(stamped).not.toContain("selfie_final_compressed_size");
310
+ expect(stamped).not.toContain("document_front_original_size");
311
+ });
312
+
313
+ it("leaves the metadata untouched when nothing was measured", async () => {
314
+ compress.mockResolvedValue({});
315
+ const api = makeApi();
316
+ await makeRepository(api).submit(selfieEnrollment);
317
+
318
+ const submitted = api.smartSelfieEnrollment.mock.calls[0]?.[0];
319
+ expect(submitted).toBe(selfieEnrollment);
320
+ });
321
+
322
+ it("submits the captured images as-is when compression throws", async () => {
323
+ compress.mockRejectedValue(new Error("native re-encoder unavailable"));
324
+ const api = makeApi();
325
+ const result = await makeRepository(api).submit(selfieEnrollment);
326
+
327
+ expect(result.status).toBe("success");
328
+ expect(api.smartSelfieEnrollment).toHaveBeenCalledTimes(1);
329
+ expect(
330
+ metadataNames(api.smartSelfieEnrollment.mock.calls[0]?.[0]),
331
+ ).not.toContain("selfie_original_size");
332
+ });
333
+ });
334
+
335
+ describe("UseSmileIDRepositoryImpl — error mapping", () => {
336
+ beforeEach(() => {
337
+ compress.mockReset().mockResolvedValue({});
338
+ });
339
+
340
+ it("surfaces a validation exception unchanged rather than as a network error", async () => {
341
+ const validationError = new UseSmileIDValidationException({
342
+ message: "liveness image count mismatch",
343
+ });
344
+ const api = makeApi();
345
+ api.smartSelfieEnrollment.mockRejectedValue(validationError);
346
+
347
+ const result = await makeRepository(api).submit(selfieEnrollment);
348
+
349
+ expect(result.status).toBe("failure");
350
+ if (result.status === "failure") {
351
+ expect(result.error).toBe(validationError);
352
+ }
353
+ });
354
+
355
+ it("maps any other thrown error into the network exception hierarchy", async () => {
356
+ const api = makeApi();
357
+ api.enhancedKyc.mockRejectedValue(new Error("socket hang up"));
358
+
359
+ const result = await makeRepository(api).submit(enhancedKyc);
360
+
361
+ expect(result.status).toBe("failure");
362
+ if (result.status === "failure") {
363
+ expect(result.error).toBeInstanceOf(UseSmileIDNetworkException);
364
+ }
365
+ });
366
+ });
@@ -51,7 +51,7 @@ const CONSENT: ConsentInformation = Object.freeze({
51
51
  // BVN path short-circuits before the API serializes it, but the field
52
52
  // is required on `BaseJobRequest` so the fixture must supply one.
53
53
  const METADATA = new UseSmileIDMetadata({
54
- sdkVersion: "12.0.1",
54
+ sdkVersion: "12.0.2",
55
55
  sdk: "react-native-expo",
56
56
  deviceOs: "",
57
57
  deviceModel: "",