@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,651 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+
5
+ // `vi.mock` is hoisted above the imports, so anything its factory closes over
6
+ // must be created with `vi.hoisted` or it is still in the TDZ when the factory
7
+ // runs.
8
+ const {
9
+ crashReporting,
10
+ fontsLoaded,
11
+ colorScheme,
12
+ rendered,
13
+ makeNetworkingFactory,
14
+ } = vi.hoisted(() => ({
15
+ crashReporting: {
16
+ enable: vi.fn(),
17
+ disable: vi.fn(),
18
+ setUserId: vi.fn(),
19
+ captureBreadcrumb: vi.fn(),
20
+ captureException: vi.fn(),
21
+ },
22
+ fontsLoaded: vi.fn(() => true),
23
+ colorScheme: { current: "light" as "light" | "dark" | null },
24
+ rendered: {
25
+ current: undefined as
26
+ | {
27
+ step: unknown;
28
+ manager: unknown;
29
+ theme: unknown;
30
+ localizations: unknown;
31
+ }
32
+ | undefined,
33
+ },
34
+ makeNetworkingFactory: vi.fn(),
35
+ }));
36
+
37
+ vi.mock("react-native", async () => {
38
+ const actual = await vi.importActual<Record<string, unknown>>("react-native");
39
+ return { ...actual, useColorScheme: () => colorScheme.current };
40
+ });
41
+
42
+ vi.mock("@smileid/usesmileid_bridge", async () => {
43
+ const actual = await vi.importActual<
44
+ typeof import("@smileid/usesmileid_bridge")
45
+ >("@smileid/usesmileid_bridge");
46
+ return { ...actual, UseSmileIDCrashReporting: crashReporting };
47
+ });
48
+
49
+ // Bundled-font registration hits `expo-font`; it is a fire-and-forget side
50
+ // effect for these tests.
51
+ vi.mock("../../theme/use_use_smile_id_fonts", () => ({
52
+ useUseSmileIDFonts: fontsLoaded,
53
+ }));
54
+
55
+ vi.mock("../../../data/factory/networking_factory", () => ({
56
+ makeNetworkingFactory,
57
+ }));
58
+
59
+ // Stand in for the graph dispatcher so these tests cover the flow runtime
60
+ // (theme + localization resolution, crash-reporting lifecycle, exit routing)
61
+ // rather than re-rendering every orchestrated screen. The stub reads the
62
+ // contexts the runtime mounts so their presence is observable.
63
+ vi.mock("../graph/screen_renderer", async () => {
64
+ const { useFlowNavigation } = await import("../flow_navigation_context");
65
+ const { useUseSmileIDTheme } = await import("../../theme/theme_context");
66
+ const { useUseSmileIDLocalizations } =
67
+ await import("../../../l10n/use_smile_id_localizations_provider");
68
+ return {
69
+ ScreenRenderer: ({ step }: { step: unknown }) => {
70
+ rendered.current = {
71
+ step,
72
+ manager: useFlowNavigation(),
73
+ theme: useUseSmileIDTheme(),
74
+ localizations: useUseSmileIDLocalizations(),
75
+ };
76
+ return null;
77
+ },
78
+ };
79
+ });
80
+
81
+ import {
82
+ FlowInvalidDispatcher,
83
+ FlowSuccessRenderer,
84
+ } from "../use_smile_id_flow";
85
+ import {
86
+ CaptureType,
87
+ ScreensBuilder,
88
+ type ScreensConfig,
89
+ } from "../dsl/builder/screens_builder";
90
+ import type { FlowConfiguration } from "../config/flow_configuration";
91
+ import type { FlowNavigationManager } from "../flow_navigation_manager";
92
+ import { UseSmileIDLocalizations } from "../../../l10n/use_smile_id_localizations";
93
+ import { AppState } from "../../../__tests__/react_native_mock";
94
+ import { BuilderValidationException } from "../../../core/exception/builder_validation_exception";
95
+ import {
96
+ MissingMLConfigurationBlockException,
97
+ MissingRequiredFieldException,
98
+ } from "../../../core/exception";
99
+ import { defaultDarkTheme, defaultLightTheme } from "../../theme/theme";
100
+ import type { UseSmileIDResult } from "../../../core/models/result";
101
+ import type { JobSubmissionResponse } from "../../../data/model/job_submission_response";
102
+ import type { ValidationStateInvalid } from "../validation/validation_state";
103
+
104
+ function buildScreens(block: (screens: ScreensBuilder) => void): ScreensConfig {
105
+ const screensBuilder = new ScreensBuilder();
106
+ block(screensBuilder);
107
+ return screensBuilder.build();
108
+ }
109
+
110
+ const selfieFlowScreens = buildScreens((screens) => {
111
+ screens.instructions();
112
+ screens.capture((capture) => {
113
+ capture.captureType = CaptureType.selfie;
114
+ });
115
+ screens.processing();
116
+ });
117
+
118
+ function makeConfiguration(
119
+ overrides: Partial<FlowConfiguration> = {},
120
+ ): FlowConfiguration {
121
+ return {
122
+ screens: selfieFlowScreens,
123
+ onResult: vi.fn(),
124
+ ...overrides,
125
+ } as FlowConfiguration;
126
+ }
127
+
128
+ function renderFlow(configuration: FlowConfiguration) {
129
+ let renderer!: ReturnType<typeof createRenderer>;
130
+ act(() => {
131
+ renderer = createRenderer(
132
+ <FlowSuccessRenderer configuration={configuration} />,
133
+ );
134
+ });
135
+ return renderer;
136
+ }
137
+
138
+ function readRendered() {
139
+ const current = rendered.current;
140
+ if (!current) throw new Error("ScreenRenderer was not mounted");
141
+ return current;
142
+ }
143
+
144
+ function activeManager(): FlowNavigationManager {
145
+ return readRendered().manager as FlowNavigationManager;
146
+ }
147
+
148
+ const partnerNetwork = {
149
+ config: {
150
+ jobType: "smart_selfie_enrollment",
151
+ token: "jwt-token",
152
+ partnerConfig: { partnerId: "002", useSandbox: false },
153
+ },
154
+ } as unknown as FlowConfiguration["network"];
155
+
156
+ beforeEach(() => {
157
+ rendered.current = undefined;
158
+ colorScheme.current = "light";
159
+ fontsLoaded.mockReset();
160
+ fontsLoaded.mockReturnValue(true);
161
+ makeNetworkingFactory.mockReset();
162
+ makeNetworkingFactory.mockReturnValue({
163
+ api: { submitJob: vi.fn() },
164
+ metadata: { snapshot: vi.fn(() => []) },
165
+ });
166
+ for (const spy of Object.values(crashReporting)) spy.mockReset();
167
+ UseSmileIDLocalizations.clearRegistry();
168
+ });
169
+
170
+ afterEach(() => {
171
+ UseSmileIDLocalizations.clearRegistry();
172
+ });
173
+
174
+ describe("FlowSuccessRenderer — screen dispatch", () => {
175
+ it("mounts the first configured screen inside the navigation provider", () => {
176
+ renderFlow(makeConfiguration());
177
+
178
+ expect((readRendered().step as { kind: string }).kind).toBe("instructions");
179
+ expect(activeManager().currentDepth).toBe(0);
180
+ });
181
+
182
+ it("re-renders on the manager's cursor moves", () => {
183
+ renderFlow(makeConfiguration());
184
+
185
+ act(() => {
186
+ activeManager().navigateToNext();
187
+ });
188
+
189
+ expect((readRendered().step as { kind: string }).kind).toBe("capture");
190
+ expect(activeManager().currentDepth).toBe(1);
191
+ });
192
+
193
+ it("renders the empty-screens hint instead of a blank screen", () => {
194
+ const renderer = renderFlow(
195
+ makeConfiguration({ screens: buildScreens(() => {}) }),
196
+ );
197
+
198
+ expect(rendered.current).toBeUndefined();
199
+ const text = JSON.stringify(renderer.toJSON());
200
+ expect(text).toContain("No screens configured");
201
+ });
202
+
203
+ it("registers the bundled fonts once per flow", () => {
204
+ renderFlow(makeConfiguration());
205
+ expect(fontsLoaded).toHaveBeenCalled();
206
+ });
207
+
208
+ it("stops notifying the renderer after unmount", () => {
209
+ const renderer = renderFlow(makeConfiguration());
210
+ const manager = activeManager();
211
+
212
+ act(() => {
213
+ renderer.unmount();
214
+ });
215
+ rendered.current = undefined;
216
+ act(() => {
217
+ manager.navigateToNext();
218
+ });
219
+
220
+ expect(rendered.current).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe("FlowSuccessRenderer — theme resolution", () => {
225
+ it("uses the SDK light defaults when the partner sets no theme", () => {
226
+ renderFlow(makeConfiguration());
227
+ expect(readRendered().theme).toBe(defaultLightTheme);
228
+ });
229
+
230
+ it("switches to the SDK dark defaults when the device is dark", () => {
231
+ colorScheme.current = "dark";
232
+ renderFlow(makeConfiguration());
233
+ expect(readRendered().theme).toBe(defaultDarkTheme);
234
+ });
235
+
236
+ it("prefers the partner's resolved light tokens in light mode", () => {
237
+ const theme = { light: defaultLightTheme, dark: defaultDarkTheme };
238
+ renderFlow(
239
+ makeConfiguration({
240
+ theme: theme as unknown as FlowConfiguration["theme"],
241
+ }),
242
+ );
243
+ expect(readRendered().theme).toBe(theme.light);
244
+ });
245
+
246
+ it("prefers the partner's resolved dark tokens in dark mode", () => {
247
+ colorScheme.current = "dark";
248
+ const partnerDark = { ...defaultDarkTheme };
249
+ renderFlow(
250
+ makeConfiguration({
251
+ theme: {
252
+ light: defaultLightTheme,
253
+ dark: partnerDark,
254
+ } as unknown as FlowConfiguration["theme"],
255
+ }),
256
+ );
257
+ expect(readRendered().theme).toBe(partnerDark);
258
+ });
259
+ });
260
+
261
+ describe("FlowSuccessRenderer — crash reporting lifecycle", () => {
262
+ it("enables reporting and stamps a per-flow session id on mount", () => {
263
+ renderFlow(makeConfiguration());
264
+
265
+ expect(crashReporting.enable).toHaveBeenCalledWith({ useSandbox: false });
266
+ expect(crashReporting.setUserId).toHaveBeenCalledTimes(1);
267
+ const [sessionId] = crashReporting.setUserId.mock.calls[0] as [string];
268
+ expect(sessionId).toMatch(/\S/);
269
+ expect(crashReporting.captureBreadcrumb).toHaveBeenCalledWith({
270
+ category: "flow.navigation",
271
+ message: "flow mounted",
272
+ });
273
+ });
274
+
275
+ it("tears down any prior client before enabling so useSandbox can change", () => {
276
+ renderFlow(makeConfiguration());
277
+
278
+ const order = crashReporting.disable.mock.invocationCallOrder[0];
279
+ const enableOrder = crashReporting.enable.mock.invocationCallOrder[0];
280
+ expect(order).toBeLessThan(enableOrder as number);
281
+ });
282
+
283
+ it("routes sandbox sessions to the sandbox reporter", () => {
284
+ renderFlow(makeConfiguration({ network: partnerNetwork }));
285
+
286
+ expect(crashReporting.enable).toHaveBeenCalledWith({ useSandbox: false });
287
+
288
+ crashReporting.enable.mockClear();
289
+ renderFlow(
290
+ makeConfiguration({
291
+ network: {
292
+ config: {
293
+ ...(partnerNetwork as { config: Record<string, unknown> }).config,
294
+ partnerConfig: { partnerId: "002", useSandbox: true },
295
+ },
296
+ } as unknown as FlowConfiguration["network"],
297
+ }),
298
+ );
299
+
300
+ expect(crashReporting.enable).toHaveBeenCalledWith({ useSandbox: true });
301
+ });
302
+
303
+ it("stays disabled when the partner opts out", () => {
304
+ renderFlow(
305
+ makeConfiguration({
306
+ config: {
307
+ enableCrashReporting: false,
308
+ } as unknown as FlowConfiguration["config"],
309
+ }),
310
+ );
311
+
312
+ expect(crashReporting.enable).not.toHaveBeenCalled();
313
+ expect(crashReporting.setUserId).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it("disables reporting when the flow unmounts", () => {
317
+ const renderer = renderFlow(makeConfiguration());
318
+ crashReporting.disable.mockClear();
319
+
320
+ act(() => {
321
+ renderer.unmount();
322
+ });
323
+
324
+ expect(crashReporting.disable).toHaveBeenCalledTimes(1);
325
+ });
326
+ });
327
+
328
+ describe("FlowSuccessRenderer — exit routing", () => {
329
+ function renderWithResultSpy() {
330
+ const onResult = vi.fn();
331
+ renderFlow(makeConfiguration({ onResult }));
332
+ return { onResult, manager: activeManager() };
333
+ }
334
+
335
+ it("delivers a successful submission as a success result", () => {
336
+ const { onResult, manager } = renderWithResultSpy();
337
+ const job: JobSubmissionResponse = {
338
+ userId: "user-1",
339
+ jobId: "job-1",
340
+ status: "approved",
341
+ message: "done",
342
+ };
343
+
344
+ act(() => {
345
+ manager.exit({ kind: "completed", job });
346
+ });
347
+
348
+ const [result] = onResult.mock.calls[0] as [
349
+ UseSmileIDResult<JobSubmissionResponse>,
350
+ ];
351
+ expect(result.status).toBe("success");
352
+ expect((result as { value: JobSubmissionResponse }).value).toBe(job);
353
+ });
354
+
355
+ it("reports a deliberate exit as cancelled, not failed", () => {
356
+ const { onResult, manager } = renderWithResultSpy();
357
+
358
+ act(() => {
359
+ manager.exit({ kind: "cancelled" });
360
+ });
361
+
362
+ expect((onResult.mock.calls[0]?.[0] as { status: string }).status).toBe(
363
+ "cancelled",
364
+ );
365
+ });
366
+
367
+ it("passes a failure exit through with its error", () => {
368
+ const { onResult, manager } = renderWithResultSpy();
369
+ const error = new Error("submission rejected");
370
+
371
+ act(() => {
372
+ manager.exit({ kind: "failed", error });
373
+ });
374
+
375
+ const [result] = onResult.mock.calls[0] as [
376
+ UseSmileIDResult<JobSubmissionResponse>,
377
+ ];
378
+ expect(result.status).toBe("failure");
379
+ expect((result as { error: unknown }).error).toBe(error);
380
+ });
381
+
382
+ it("captures a throwing partner handler instead of crashing the SDK", () => {
383
+ const boom = new Error("partner navigation stack is gone");
384
+ const onResult = vi.fn(() => {
385
+ throw boom;
386
+ });
387
+ renderFlow(makeConfiguration({ onResult }));
388
+ const manager = activeManager();
389
+
390
+ expect(() => {
391
+ act(() => {
392
+ manager.exit({ kind: "cancelled" });
393
+ });
394
+ }).not.toThrow();
395
+ expect(crashReporting.captureException).toHaveBeenCalledWith(boom);
396
+ });
397
+ });
398
+
399
+ describe("FlowSuccessRenderer — repository wiring", () => {
400
+ it("builds the networking stack once per network config", () => {
401
+ renderFlow(makeConfiguration({ network: partnerNetwork }));
402
+
403
+ expect(makeNetworkingFactory).toHaveBeenCalledTimes(1);
404
+ expect(makeNetworkingFactory).toHaveBeenCalledWith(partnerNetwork);
405
+ });
406
+
407
+ it("skips the networking stack when no partnerConfig is supplied", () => {
408
+ renderFlow(
409
+ makeConfiguration({
410
+ network: {
411
+ config: { jobType: "smart_selfie_enrollment" },
412
+ } as unknown as FlowConfiguration["network"],
413
+ }),
414
+ );
415
+
416
+ expect(makeNetworkingFactory).not.toHaveBeenCalled();
417
+ });
418
+
419
+ it("surfaces the 'not configured' failure when the flow has no repository", async () => {
420
+ renderFlow(makeConfiguration());
421
+ const manager = activeManager();
422
+
423
+ await act(async () => {
424
+ manager.beginSubmissionIfNeeded();
425
+ });
426
+
427
+ const state = manager.jobSubmissionState;
428
+ expect(state.kind).toBe("failure");
429
+ expect((state as { error: Error }).error.message).toContain(
430
+ "Job submission not configured",
431
+ );
432
+ });
433
+
434
+ it("hands the manager a real repository once partnerConfig is present", async () => {
435
+ renderFlow(makeConfiguration({ network: partnerNetwork }));
436
+ const manager = activeManager();
437
+
438
+ await act(async () => {
439
+ manager.beginSubmissionIfNeeded();
440
+ });
441
+
442
+ const state = manager.jobSubmissionState;
443
+ expect(state.kind).toBe("failure");
444
+ // The repository exists, so submission gets far enough to fail on the
445
+ // missing selfie capture rather than on the absent network stack.
446
+ expect((state as { error: Error }).error.message).not.toContain(
447
+ "Job submission not configured",
448
+ );
449
+ });
450
+ });
451
+
452
+ describe("FlowSuccessRenderer — localizations", () => {
453
+ it("uses the partner's explicit localizations over everything else", () => {
454
+ const localizations = new UseSmileIDLocalizations({
455
+ si_consent_title: "Explicit",
456
+ });
457
+ renderFlow(makeConfiguration({ localizations }));
458
+
459
+ expect(readRendered().localizations).toBe(localizations);
460
+ });
461
+
462
+ it("auto-resolves from the registry when the partner registered locales", () => {
463
+ UseSmileIDLocalizations.register("en", { si_consent_title: "Registered" });
464
+ renderFlow(makeConfiguration());
465
+
466
+ const resolved = readRendered().localizations as UseSmileIDLocalizations;
467
+ expect(resolved).toBeInstanceOf(UseSmileIDLocalizations);
468
+ expect(resolved).not.toBe(undefined);
469
+ });
470
+
471
+ it("re-resolves the locale when the app returns to the foreground", () => {
472
+ UseSmileIDLocalizations.register("en", { si_consent_allow: "Allow" });
473
+ UseSmileIDLocalizations.register("fr", { si_consent_allow: "Autoriser" });
474
+ const originalResolver = UseSmileIDLocalizations.localeResolver;
475
+ UseSmileIDLocalizations.localeResolver = () => "en";
476
+
477
+ try {
478
+ renderFlow(makeConfiguration());
479
+ expect(
480
+ (readRendered().localizations as UseSmileIDLocalizations)
481
+ .siConsentAllow,
482
+ ).toBe("Allow");
483
+
484
+ UseSmileIDLocalizations.localeResolver = () => "fr";
485
+ act(() => {
486
+ AppState.emit("active");
487
+ });
488
+
489
+ expect(
490
+ (readRendered().localizations as UseSmileIDLocalizations)
491
+ .siConsentAllow,
492
+ ).toBe("Autoriser");
493
+ } finally {
494
+ UseSmileIDLocalizations.localeResolver = originalResolver;
495
+ }
496
+ });
497
+
498
+ it("falls back to the English context default with no registered locales", () => {
499
+ renderFlow(makeConfiguration());
500
+
501
+ const resolved = readRendered().localizations as UseSmileIDLocalizations;
502
+ expect(resolved).toBeInstanceOf(UseSmileIDLocalizations);
503
+ });
504
+ });
505
+
506
+ describe("FlowSuccessRenderer — font registration gate", () => {
507
+ it("holds the first frame until the bundled faces are registered", () => {
508
+ fontsLoaded.mockReturnValue(false);
509
+ renderFlow(makeConfiguration());
510
+
511
+ expect(rendered.current).toBeUndefined();
512
+ });
513
+
514
+ it("renders the flow once registration completes", () => {
515
+ fontsLoaded.mockReturnValue(false);
516
+ const renderer = renderFlow(makeConfiguration());
517
+
518
+ fontsLoaded.mockReturnValue(true);
519
+ act(() => {
520
+ renderer.update(
521
+ <FlowSuccessRenderer configuration={makeConfiguration()} />,
522
+ );
523
+ });
524
+
525
+ expect(rendered.current).toBeDefined();
526
+ });
527
+
528
+ it("renders anyway once the wait expires, so a font failure can't strand the flow", () => {
529
+ vi.useFakeTimers();
530
+ try {
531
+ fontsLoaded.mockReturnValue(false);
532
+ renderFlow(makeConfiguration());
533
+ expect(rendered.current).toBeUndefined();
534
+
535
+ act(() => {
536
+ vi.advanceTimersByTime(1000);
537
+ });
538
+
539
+ expect(rendered.current).toBeDefined();
540
+ } finally {
541
+ vi.useRealTimers();
542
+ }
543
+ });
544
+ });
545
+
546
+ describe("FlowInvalidDispatcher", () => {
547
+ function makeValidationState(): ValidationStateInvalid {
548
+ return {
549
+ valid: false,
550
+ issues: [
551
+ new MissingRequiredFieldException({ fieldName: "captureType" }),
552
+ new MissingMLConfigurationBlockException(),
553
+ ],
554
+ } as ValidationStateInvalid;
555
+ }
556
+
557
+ function renderDispatcher(
558
+ onResult: (result: UseSmileIDResult<JobSubmissionResponse>) => void,
559
+ enableDebugMode: boolean,
560
+ validation = makeValidationState(),
561
+ ) {
562
+ let renderer!: ReturnType<typeof createRenderer>;
563
+ act(() => {
564
+ renderer = createRenderer(
565
+ <FlowInvalidDispatcher
566
+ result={{ kind: "invalid", validation }}
567
+ onResult={onResult}
568
+ enableDebugMode={enableDebugMode}
569
+ />,
570
+ );
571
+ });
572
+ return renderer;
573
+ }
574
+
575
+ it("reports the validation failure through onResult as a typed exception", () => {
576
+ const onResult = vi.fn();
577
+ const validation = makeValidationState();
578
+ renderDispatcher(onResult, false, validation);
579
+
580
+ const [result] = onResult.mock.calls[0] as [
581
+ UseSmileIDResult<JobSubmissionResponse>,
582
+ ];
583
+ expect(result.status).toBe("failure");
584
+ const error = (result as { error: unknown }).error;
585
+ expect(error).toBeInstanceOf(BuilderValidationException);
586
+ expect(
587
+ (error as BuilderValidationException).validationState.issues,
588
+ ).toHaveLength(2);
589
+ });
590
+
591
+ it("renders nothing in production so partners only see onResult", () => {
592
+ const renderer = renderDispatcher(vi.fn(), false);
593
+ expect(renderer.toJSON()).toBeNull();
594
+ });
595
+
596
+ it("renders every failing rule in debug mode", () => {
597
+ const renderer = renderDispatcher(vi.fn(), true);
598
+
599
+ const text = JSON.stringify(renderer.toJSON());
600
+ expect(text).toContain("UseSmileID Configuration Error");
601
+ expect(text).toContain("2");
602
+ expect(text).toContain("Required field 'captureType' is missing");
603
+ expect(text).toContain("Provide a value for the required field.");
604
+ expect(text).toContain(
605
+ "Flow contains a Capture screen but the ml { } block is missing",
606
+ );
607
+ // Errors render crimson, warnings amber — the two severities are
608
+ // distinguishable at a glance in the debug screen.
609
+ expect(text).toContain("#B91C1C");
610
+ expect(text).toContain("#B45309");
611
+ });
612
+
613
+ it("captures a throwing partner handler rather than crashing the mount", () => {
614
+ const boom = new Error("partner blew up on failure");
615
+ expect(() =>
616
+ renderDispatcher(() => {
617
+ throw boom;
618
+ }, false),
619
+ ).not.toThrow();
620
+ expect(crashReporting.captureException).toHaveBeenCalledWith(boom);
621
+ });
622
+ });
623
+
624
+ describe("FlowSuccessRenderer — teardown delivery", () => {
625
+ it("an unmounted-before-finishing flow delivers a cancelled result exactly once", () => {
626
+ const onResult = vi.fn();
627
+ const renderer = renderFlow(makeConfiguration({ onResult }));
628
+
629
+ act(() => {
630
+ renderer.unmount();
631
+ });
632
+
633
+ expect(onResult).toHaveBeenCalledTimes(1);
634
+ expect(onResult.mock.calls[0]?.[0]).toMatchObject({ status: "cancelled" });
635
+ });
636
+
637
+ it("a finished flow delivers nothing extra at unmount", () => {
638
+ const onResult = vi.fn();
639
+ const renderer = renderFlow(makeConfiguration({ onResult }));
640
+ const manager = activeManager();
641
+
642
+ act(() => {
643
+ manager.exit({ kind: "cancelled" });
644
+ });
645
+ act(() => {
646
+ renderer.unmount();
647
+ });
648
+
649
+ expect(onResult).toHaveBeenCalledTimes(1);
650
+ });
651
+ });