@harkenapp/sdk-react-native 0.0.1-alpha.1 → 0.0.1-alpha.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 (278) hide show
  1. package/README.md +44 -7
  2. package/app.plugin.cjs +12 -17
  3. package/dist/__mocks__/async-storage.d.ts +16 -0
  4. package/dist/__mocks__/async-storage.d.ts.map +1 -0
  5. package/dist/__mocks__/async-storage.js +39 -0
  6. package/dist/__mocks__/async-storage.js.map +1 -0
  7. package/dist/__mocks__/expo-document-picker.d.ts +26 -0
  8. package/dist/__mocks__/expo-document-picker.d.ts.map +1 -0
  9. package/dist/__mocks__/expo-document-picker.js +25 -0
  10. package/dist/__mocks__/expo-document-picker.js.map +1 -0
  11. package/dist/__mocks__/expo-file-system.d.ts +42 -0
  12. package/dist/__mocks__/expo-file-system.d.ts.map +1 -0
  13. package/dist/__mocks__/expo-file-system.js +37 -0
  14. package/dist/__mocks__/expo-file-system.js.map +1 -0
  15. package/dist/__mocks__/expo-image-picker.d.ts +30 -0
  16. package/dist/__mocks__/expo-image-picker.d.ts.map +1 -0
  17. package/dist/__mocks__/expo-image-picker.js +30 -0
  18. package/dist/__mocks__/expo-image-picker.js.map +1 -0
  19. package/dist/__mocks__/expo-secure-store.d.ts +15 -0
  20. package/dist/__mocks__/expo-secure-store.d.ts.map +1 -0
  21. package/dist/__mocks__/expo-secure-store.js +30 -0
  22. package/dist/__mocks__/expo-secure-store.js.map +1 -0
  23. package/dist/__mocks__/react-native.d.ts +73 -0
  24. package/dist/__mocks__/react-native.d.ts.map +1 -0
  25. package/dist/__mocks__/react-native.js +45 -0
  26. package/dist/__mocks__/react-native.js.map +1 -0
  27. package/dist/api/client.d.ts +8 -8
  28. package/dist/api/client.d.ts.map +1 -1
  29. package/dist/api/client.js +17 -19
  30. package/dist/api/client.js.map +1 -1
  31. package/dist/api/client.test.d.ts +2 -0
  32. package/dist/api/client.test.d.ts.map +1 -0
  33. package/dist/api/client.test.js +417 -0
  34. package/dist/api/client.test.js.map +1 -0
  35. package/dist/api/errors.d.ts +3 -3
  36. package/dist/api/errors.d.ts.map +1 -1
  37. package/dist/api/errors.js +3 -3
  38. package/dist/api/errors.js.map +1 -1
  39. package/dist/api/errors.test.d.ts +2 -0
  40. package/dist/api/errors.test.d.ts.map +1 -0
  41. package/dist/api/errors.test.js +155 -0
  42. package/dist/api/errors.test.js.map +1 -0
  43. package/dist/api/index.d.ts +6 -6
  44. package/dist/api/index.d.ts.map +1 -1
  45. package/dist/api/index.js.map +1 -1
  46. package/dist/api/retry.d.ts +1 -1
  47. package/dist/api/retry.d.ts.map +1 -1
  48. package/dist/api/retry.js.map +1 -1
  49. package/dist/api/retry.test.d.ts +2 -0
  50. package/dist/api/retry.test.d.ts.map +1 -0
  51. package/dist/api/retry.test.js +193 -0
  52. package/dist/api/retry.test.js.map +1 -0
  53. package/dist/attachments/FeedbackSheet.d.ts +36 -13
  54. package/dist/attachments/FeedbackSheet.d.ts.map +1 -1
  55. package/dist/attachments/FeedbackSheet.js +50 -30
  56. package/dist/attachments/FeedbackSheet.js.map +1 -1
  57. package/dist/attachments/index.d.ts +2 -2
  58. package/dist/components/AttachmentGrid.d.ts +12 -4
  59. package/dist/components/AttachmentGrid.d.ts.map +1 -1
  60. package/dist/components/AttachmentGrid.js +44 -34
  61. package/dist/components/AttachmentGrid.js.map +1 -1
  62. package/dist/components/AttachmentPicker.d.ts +3 -3
  63. package/dist/components/AttachmentPicker.d.ts.map +1 -1
  64. package/dist/components/AttachmentPicker.js +34 -36
  65. package/dist/components/AttachmentPicker.js.map +1 -1
  66. package/dist/components/AttachmentPreview.d.ts +10 -4
  67. package/dist/components/AttachmentPreview.d.ts.map +1 -1
  68. package/dist/components/AttachmentPreview.js +48 -34
  69. package/dist/components/AttachmentPreview.js.map +1 -1
  70. package/dist/components/CategorySelector.d.ts +3 -3
  71. package/dist/components/CategorySelector.d.ts.map +1 -1
  72. package/dist/components/CategorySelector.js +21 -27
  73. package/dist/components/CategorySelector.js.map +1 -1
  74. package/dist/components/FeedbackForm.d.ts +3 -3
  75. package/dist/components/FeedbackForm.d.ts.map +1 -1
  76. package/dist/components/FeedbackForm.js +7 -8
  77. package/dist/components/FeedbackForm.js.map +1 -1
  78. package/dist/components/FeedbackSheet.d.ts +34 -11
  79. package/dist/components/FeedbackSheet.d.ts.map +1 -1
  80. package/dist/components/FeedbackSheet.js +46 -28
  81. package/dist/components/FeedbackSheet.js.map +1 -1
  82. package/dist/components/ThemedButton.d.ts +16 -5
  83. package/dist/components/ThemedButton.d.ts.map +1 -1
  84. package/dist/components/ThemedButton.js +38 -29
  85. package/dist/components/ThemedButton.js.map +1 -1
  86. package/dist/components/ThemedText.d.ts +3 -3
  87. package/dist/components/ThemedText.d.ts.map +1 -1
  88. package/dist/components/ThemedText.js +1 -1
  89. package/dist/components/ThemedText.js.map +1 -1
  90. package/dist/components/ThemedTextInput.d.ts +11 -2
  91. package/dist/components/ThemedTextInput.d.ts.map +1 -1
  92. package/dist/components/ThemedTextInput.js +19 -9
  93. package/dist/components/ThemedTextInput.js.map +1 -1
  94. package/dist/components/UploadStatusOverlay.d.ts +11 -3
  95. package/dist/components/UploadStatusOverlay.d.ts.map +1 -1
  96. package/dist/components/UploadStatusOverlay.js +59 -76
  97. package/dist/components/UploadStatusOverlay.js.map +1 -1
  98. package/dist/components/index.d.ts +18 -18
  99. package/dist/components/index.d.ts.map +1 -1
  100. package/dist/components/index.js.map +1 -1
  101. package/dist/context/HarkenContext.d.ts +20 -15
  102. package/dist/context/HarkenContext.d.ts.map +1 -1
  103. package/dist/context/HarkenContext.js +20 -17
  104. package/dist/context/HarkenContext.js.map +1 -1
  105. package/dist/context/index.d.ts +2 -2
  106. package/dist/domain/index.d.ts +2 -2
  107. package/dist/domain/index.d.ts.map +1 -1
  108. package/dist/domain/index.js.map +1 -1
  109. package/dist/hooks/index.d.ts +5 -5
  110. package/dist/hooks/useAnonymousId.js +1 -1
  111. package/dist/hooks/useAnonymousId.test.d.ts +2 -0
  112. package/dist/hooks/useAnonymousId.test.d.ts.map +1 -0
  113. package/dist/hooks/useAnonymousId.test.js +154 -0
  114. package/dist/hooks/useAnonymousId.test.js.map +1 -0
  115. package/dist/hooks/useAttachmentPicker.d.ts +3 -3
  116. package/dist/hooks/useAttachmentPicker.js +7 -7
  117. package/dist/hooks/useAttachmentStatus.d.ts +1 -1
  118. package/dist/hooks/useAttachmentStatus.d.ts.map +1 -1
  119. package/dist/hooks/useAttachmentStatus.js.map +1 -1
  120. package/dist/hooks/useAttachmentUpload.d.ts +2 -2
  121. package/dist/hooks/useAttachmentUpload.d.ts.map +1 -1
  122. package/dist/hooks/useAttachmentUpload.js +5 -5
  123. package/dist/hooks/useAttachmentUpload.js.map +1 -1
  124. package/dist/hooks/useAttachmentUpload.test.d.ts +2 -0
  125. package/dist/hooks/useAttachmentUpload.test.d.ts.map +1 -0
  126. package/dist/hooks/useAttachmentUpload.test.js +542 -0
  127. package/dist/hooks/useAttachmentUpload.test.js.map +1 -0
  128. package/dist/hooks/useFeedback.d.ts +4 -4
  129. package/dist/hooks/useFeedback.d.ts.map +1 -1
  130. package/dist/hooks/useFeedback.js +3 -5
  131. package/dist/hooks/useFeedback.js.map +1 -1
  132. package/dist/hooks/useFeedback.test.d.ts +2 -0
  133. package/dist/hooks/useFeedback.test.d.ts.map +1 -0
  134. package/dist/hooks/useFeedback.test.js +299 -0
  135. package/dist/hooks/useFeedback.test.js.map +1 -0
  136. package/dist/hooks/useHarkenContext.d.ts +1 -1
  137. package/dist/hooks/useHarkenContext.js +1 -1
  138. package/dist/hooks/useHarkenTheme.d.ts +27 -3
  139. package/dist/hooks/useHarkenTheme.d.ts.map +1 -1
  140. package/dist/hooks/useHarkenTheme.js +26 -2
  141. package/dist/hooks/useHarkenTheme.js.map +1 -1
  142. package/dist/index.d.ts +28 -28
  143. package/dist/index.d.ts.map +1 -1
  144. package/dist/index.js.map +1 -1
  145. package/dist/services/index.d.ts +3 -3
  146. package/dist/services/index.d.ts.map +1 -1
  147. package/dist/services/index.js.map +1 -1
  148. package/dist/services/uploadQueueService.d.ts +2 -2
  149. package/dist/services/uploadQueueService.d.ts.map +1 -1
  150. package/dist/services/uploadQueueService.js +16 -17
  151. package/dist/services/uploadQueueService.js.map +1 -1
  152. package/dist/services/uploadQueueService.test.d.ts +2 -0
  153. package/dist/services/uploadQueueService.test.d.ts.map +1 -0
  154. package/dist/services/uploadQueueService.test.js +426 -0
  155. package/dist/services/uploadQueueService.test.js.map +1 -0
  156. package/dist/services/uploadQueueStorage.d.ts +1 -1
  157. package/dist/services/uploadQueueStorage.d.ts.map +1 -1
  158. package/dist/services/uploadQueueStorage.js +4 -4
  159. package/dist/services/uploadQueueStorage.js.map +1 -1
  160. package/dist/services/uploadQueueStorage.test.d.ts +2 -0
  161. package/dist/services/uploadQueueStorage.test.d.ts.map +1 -0
  162. package/dist/services/uploadQueueStorage.test.js +200 -0
  163. package/dist/services/uploadQueueStorage.test.js.map +1 -0
  164. package/dist/storage/IdentityStore.d.ts +1 -1
  165. package/dist/storage/IdentityStore.d.ts.map +1 -1
  166. package/dist/storage/IdentityStore.js.map +1 -1
  167. package/dist/storage/IdentityStore.test.d.ts +2 -0
  168. package/dist/storage/IdentityStore.test.d.ts.map +1 -0
  169. package/dist/storage/IdentityStore.test.js +176 -0
  170. package/dist/storage/IdentityStore.test.js.map +1 -0
  171. package/dist/storage/SecureStoreAdapter.d.ts +1 -1
  172. package/dist/storage/SecureStoreAdapter.test.d.ts +2 -0
  173. package/dist/storage/SecureStoreAdapter.test.d.ts.map +1 -0
  174. package/dist/storage/SecureStoreAdapter.test.js +114 -0
  175. package/dist/storage/SecureStoreAdapter.test.js.map +1 -0
  176. package/dist/storage/defaultStorage.d.ts +1 -1
  177. package/dist/storage/defaultStorage.js +4 -4
  178. package/dist/storage/defaultStorage.test.d.ts +2 -0
  179. package/dist/storage/defaultStorage.test.d.ts.map +1 -0
  180. package/dist/storage/defaultStorage.test.js +159 -0
  181. package/dist/storage/defaultStorage.test.js.map +1 -0
  182. package/dist/storage/index.d.ts +5 -5
  183. package/dist/storage/types.js +1 -1
  184. package/dist/theme/defaults.d.ts +14 -3
  185. package/dist/theme/defaults.d.ts.map +1 -1
  186. package/dist/theme/defaults.js +58 -43
  187. package/dist/theme/defaults.js.map +1 -1
  188. package/dist/theme/index.d.ts +3 -2
  189. package/dist/theme/index.d.ts.map +1 -1
  190. package/dist/theme/index.js +4 -1
  191. package/dist/theme/index.js.map +1 -1
  192. package/dist/theme/resolver.d.ts +16 -0
  193. package/dist/theme/resolver.d.ts.map +1 -0
  194. package/dist/theme/resolver.js +375 -0
  195. package/dist/theme/resolver.js.map +1 -0
  196. package/dist/theme/resolver.test.d.ts +2 -0
  197. package/dist/theme/resolver.test.d.ts.map +1 -0
  198. package/dist/theme/resolver.test.js +344 -0
  199. package/dist/theme/resolver.test.js.map +1 -0
  200. package/dist/theme/types.d.ts +378 -5
  201. package/dist/theme/types.d.ts.map +1 -1
  202. package/dist/types/config.d.ts +4 -4
  203. package/dist/types/index.d.ts +2 -2
  204. package/dist/utils/index.d.ts +1 -1
  205. package/dist/utils/uuid.d.ts.map +1 -1
  206. package/dist/utils/uuid.js +4 -5
  207. package/dist/utils/uuid.js.map +1 -1
  208. package/dist/utils/uuid.test.d.ts +2 -0
  209. package/dist/utils/uuid.test.d.ts.map +1 -0
  210. package/dist/utils/uuid.test.js +78 -0
  211. package/dist/utils/uuid.test.js.map +1 -0
  212. package/package.json +21 -13
  213. package/src/@types/expo-file-system-legacy.d.ts +3 -3
  214. package/src/__mocks__/async-storage.ts +46 -0
  215. package/src/__mocks__/expo-document-picker.ts +41 -0
  216. package/src/__mocks__/expo-file-system.ts +62 -0
  217. package/src/__mocks__/expo-image-picker.ts +48 -0
  218. package/src/__mocks__/expo-secure-store.ts +29 -0
  219. package/src/__mocks__/react-native.ts +46 -0
  220. package/src/api/client.test.ts +515 -0
  221. package/src/api/client.ts +45 -64
  222. package/src/api/errors.test.ts +193 -0
  223. package/src/api/errors.ts +7 -11
  224. package/src/api/index.ts +6 -10
  225. package/src/api/retry.test.ts +251 -0
  226. package/src/api/retry.ts +3 -6
  227. package/src/attachments/FeedbackSheet.tsx +100 -80
  228. package/src/attachments/index.ts +2 -2
  229. package/src/components/AttachmentGrid.tsx +54 -45
  230. package/src/components/AttachmentPicker.tsx +43 -54
  231. package/src/components/AttachmentPreview.tsx +51 -47
  232. package/src/components/CategorySelector.tsx +29 -35
  233. package/src/components/FeedbackForm.tsx +23 -35
  234. package/src/components/FeedbackSheet.tsx +89 -68
  235. package/src/components/ThemedButton.tsx +49 -47
  236. package/src/components/ThemedText.tsx +7 -10
  237. package/src/components/ThemedTextInput.tsx +23 -13
  238. package/src/components/UploadStatusOverlay.tsx +66 -89
  239. package/src/components/index.ts +18 -21
  240. package/src/context/HarkenContext.tsx +29 -28
  241. package/src/context/index.ts +2 -2
  242. package/src/domain/index.ts +2 -5
  243. package/src/domain/upload-queue.ts +5 -5
  244. package/src/hooks/index.ts +5 -5
  245. package/src/hooks/useAnonymousId.test.ts +189 -0
  246. package/src/hooks/useAnonymousId.ts +3 -3
  247. package/src/hooks/useAttachmentPicker.ts +12 -12
  248. package/src/hooks/useAttachmentStatus.ts +12 -16
  249. package/src/hooks/useAttachmentUpload.test.ts +632 -0
  250. package/src/hooks/useAttachmentUpload.ts +45 -54
  251. package/src/hooks/useFeedback.test.ts +376 -0
  252. package/src/hooks/useFeedback.ts +12 -14
  253. package/src/hooks/useHarkenContext.ts +4 -4
  254. package/src/hooks/useHarkenTheme.ts +30 -6
  255. package/src/index.ts +28 -52
  256. package/src/services/index.ts +3 -9
  257. package/src/services/uploadQueueService.test.ts +489 -0
  258. package/src/services/uploadQueueService.ts +40 -56
  259. package/src/services/uploadQueueStorage.test.ts +243 -0
  260. package/src/services/uploadQueueStorage.ts +7 -9
  261. package/src/storage/IdentityStore.test.ts +173 -0
  262. package/src/storage/IdentityStore.ts +4 -5
  263. package/src/storage/SecureStoreAdapter.test.ts +147 -0
  264. package/src/storage/SecureStoreAdapter.ts +1 -1
  265. package/src/storage/defaultStorage.test.ts +159 -0
  266. package/src/storage/defaultStorage.ts +6 -6
  267. package/src/storage/index.ts +5 -5
  268. package/src/storage/types.ts +1 -1
  269. package/src/theme/defaults.ts +75 -46
  270. package/src/theme/index.ts +15 -2
  271. package/src/theme/resolver.test.ts +411 -0
  272. package/src/theme/resolver.ts +446 -0
  273. package/src/theme/types.ts +453 -15
  274. package/src/types/config.ts +4 -4
  275. package/src/types/index.ts +2 -2
  276. package/src/utils/index.ts +1 -1
  277. package/src/utils/uuid.test.ts +85 -0
  278. package/src/utils/uuid.ts +4 -7
@@ -0,0 +1,632 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from "vitest";
5
+ import { renderHook, act, waitFor } from "@testing-library/react";
6
+ import { useAttachmentUpload } from "./useAttachmentUpload";
7
+ import { UploadPhase } from "../domain";
8
+ import type { UploadProgress } from "../domain";
9
+
10
+ // Mock uploadQueueService
11
+ const mockEnqueue = vi.fn();
12
+ const mockRetryItem = vi.fn();
13
+ const mockCancelItem = vi.fn();
14
+ const mockClearCompleted = vi.fn();
15
+ const mockClearFailed = vi.fn();
16
+ let mockProgressCallback: ((progress: UploadProgress) => void) | null = null;
17
+
18
+ vi.mock("../services", () => ({
19
+ uploadQueueService: {
20
+ initialized: true,
21
+ initialize: vi.fn(),
22
+ enqueue: (...args: unknown[]) => mockEnqueue(...args),
23
+ retryItem: (...args: unknown[]) => mockRetryItem(...args),
24
+ cancelItem: (...args: unknown[]) => mockCancelItem(...args),
25
+ clearCompleted: (...args: unknown[]) => mockClearCompleted(...args),
26
+ clearFailed: (...args: unknown[]) => mockClearFailed(...args),
27
+ onProgress: (cb: (progress: UploadProgress) => void) => {
28
+ mockProgressCallback = cb;
29
+ return () => {
30
+ mockProgressCallback = null;
31
+ };
32
+ },
33
+ },
34
+ }));
35
+
36
+ // Mock ImagePicker
37
+ const mockLaunchCameraAsync = vi.fn();
38
+ const mockLaunchImageLibraryAsync = vi.fn();
39
+
40
+ vi.mock("expo-image-picker", () => ({
41
+ launchCameraAsync: (...args: unknown[]) => mockLaunchCameraAsync(...args),
42
+ launchImageLibraryAsync: (...args: unknown[]) => mockLaunchImageLibraryAsync(...args),
43
+ }));
44
+
45
+ // Mock DocumentPicker
46
+ const mockGetDocumentAsync = vi.fn();
47
+
48
+ vi.mock("expo-document-picker", () => ({
49
+ getDocumentAsync: (...args: unknown[]) => mockGetDocumentAsync(...args),
50
+ }));
51
+
52
+ // Mock FileSystem
53
+ vi.mock("expo-file-system/legacy", () => ({
54
+ getInfoAsync: vi.fn().mockResolvedValue({ exists: true, size: 1000 }),
55
+ }));
56
+
57
+ // Mock useHarkenContext
58
+ vi.mock("./useHarkenContext", () => ({
59
+ useHarkenContext: vi.fn(() => ({
60
+ client: {},
61
+ config: { debug: false },
62
+ })),
63
+ }));
64
+
65
+ describe("useAttachmentUpload", () => {
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ mockProgressCallback = null;
69
+ });
70
+
71
+ describe("pickImage", () => {
72
+ it("adds attachment with correct metadata from camera", async () => {
73
+ mockLaunchCameraAsync.mockResolvedValue({
74
+ canceled: false,
75
+ assets: [
76
+ {
77
+ uri: "file:///path/to/photo.jpg",
78
+ fileName: "photo.jpg",
79
+ mimeType: "image/jpeg",
80
+ fileSize: 12345,
81
+ },
82
+ ],
83
+ });
84
+ mockEnqueue.mockResolvedValue({
85
+ attachmentId: "att_123",
86
+ queueItemId: "queue_1",
87
+ });
88
+
89
+ const { result } = renderHook(() => useAttachmentUpload());
90
+
91
+ let attachment: unknown;
92
+ await act(async () => {
93
+ attachment = await result.current.pickImage("camera");
94
+ });
95
+
96
+ expect(mockLaunchCameraAsync).toHaveBeenCalled();
97
+ expect(mockEnqueue).toHaveBeenCalledWith({
98
+ localUri: "file:///path/to/photo.jpg",
99
+ mimeType: "image/jpeg",
100
+ fileName: "photo.jpg",
101
+ fileSize: 12345,
102
+ });
103
+ expect(attachment).toMatchObject({
104
+ attachmentId: "att_123",
105
+ localUri: "file:///path/to/photo.jpg",
106
+ phase: UploadPhase.QUEUED,
107
+ });
108
+ });
109
+
110
+ it("adds attachment from library", async () => {
111
+ mockLaunchImageLibraryAsync.mockResolvedValue({
112
+ canceled: false,
113
+ assets: [
114
+ {
115
+ uri: "file:///path/to/image.png",
116
+ fileName: "screenshot.png",
117
+ mimeType: "image/png",
118
+ fileSize: 54321,
119
+ },
120
+ ],
121
+ });
122
+ mockEnqueue.mockResolvedValue({
123
+ attachmentId: "att_456",
124
+ queueItemId: "queue_2",
125
+ });
126
+
127
+ const { result } = renderHook(() => useAttachmentUpload());
128
+
129
+ await act(async () => {
130
+ await result.current.pickImage("library");
131
+ });
132
+
133
+ expect(mockLaunchImageLibraryAsync).toHaveBeenCalled();
134
+ expect(mockEnqueue).toHaveBeenCalledWith(
135
+ expect.objectContaining({
136
+ localUri: "file:///path/to/image.png",
137
+ mimeType: "image/png",
138
+ })
139
+ );
140
+ });
141
+
142
+ it("returns null when user cancels", async () => {
143
+ mockLaunchCameraAsync.mockResolvedValue({ canceled: true, assets: [] });
144
+
145
+ const { result } = renderHook(() => useAttachmentUpload());
146
+
147
+ let attachment: unknown;
148
+ await act(async () => {
149
+ attachment = await result.current.pickImage("camera");
150
+ });
151
+
152
+ expect(attachment).toBeNull();
153
+ expect(mockEnqueue).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe("pickDocument", () => {
158
+ it("adds document with correct metadata", async () => {
159
+ mockGetDocumentAsync.mockResolvedValue({
160
+ canceled: false,
161
+ assets: [
162
+ {
163
+ uri: "file:///path/to/document.pdf",
164
+ name: "report.pdf",
165
+ mimeType: "application/pdf",
166
+ size: 98765,
167
+ },
168
+ ],
169
+ });
170
+ mockEnqueue.mockResolvedValue({
171
+ attachmentId: "att_789",
172
+ queueItemId: "queue_3",
173
+ });
174
+
175
+ const { result } = renderHook(() => useAttachmentUpload());
176
+
177
+ let attachment: unknown;
178
+ await act(async () => {
179
+ attachment = await result.current.pickDocument();
180
+ });
181
+
182
+ expect(mockGetDocumentAsync).toHaveBeenCalledWith({
183
+ type: ["image/*", "application/pdf"],
184
+ copyToCacheDirectory: true,
185
+ });
186
+ expect(mockEnqueue).toHaveBeenCalledWith({
187
+ localUri: "file:///path/to/document.pdf",
188
+ mimeType: "application/pdf",
189
+ fileName: "report.pdf",
190
+ fileSize: 98765,
191
+ });
192
+ expect(attachment).toMatchObject({
193
+ attachmentId: "att_789",
194
+ fileName: "report.pdf",
195
+ });
196
+ });
197
+
198
+ it("returns null when user cancels", async () => {
199
+ mockGetDocumentAsync.mockResolvedValue({ canceled: true, assets: [] });
200
+
201
+ const { result } = renderHook(() => useAttachmentUpload());
202
+
203
+ let attachment: unknown;
204
+ await act(async () => {
205
+ attachment = await result.current.pickDocument();
206
+ });
207
+
208
+ expect(attachment).toBeNull();
209
+ expect(mockEnqueue).not.toHaveBeenCalled();
210
+ });
211
+ });
212
+
213
+ describe("progress events", () => {
214
+ it("updates state only for tracked attachment IDs", async () => {
215
+ mockLaunchCameraAsync.mockResolvedValue({
216
+ canceled: false,
217
+ assets: [
218
+ {
219
+ uri: "file:///photo.jpg",
220
+ fileName: "photo.jpg",
221
+ mimeType: "image/jpeg",
222
+ fileSize: 1000,
223
+ },
224
+ ],
225
+ });
226
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_tracked", queueItemId: "queue_1" });
227
+
228
+ const { result } = renderHook(() => useAttachmentUpload());
229
+
230
+ // Add an attachment
231
+ await act(async () => {
232
+ await result.current.pickImage("camera");
233
+ });
234
+
235
+ expect(result.current.attachments).toHaveLength(1);
236
+ expect(result.current.attachments[0]?.progress).toBe(0);
237
+
238
+ // Simulate progress event for tracked ID
239
+ act(() => {
240
+ mockProgressCallback?.({
241
+ attachmentId: "att_tracked",
242
+ phase: UploadPhase.UPLOADING,
243
+ progress: 0.5,
244
+ });
245
+ });
246
+
247
+ await waitFor(() => {
248
+ expect(result.current.attachments[0]?.progress).toBe(0.5);
249
+ expect(result.current.attachments[0]?.phase).toBe(UploadPhase.UPLOADING);
250
+ });
251
+
252
+ // Simulate progress event for untracked ID - should not affect state
253
+ act(() => {
254
+ mockProgressCallback?.({
255
+ attachmentId: "att_untracked",
256
+ phase: UploadPhase.UPLOADING,
257
+ progress: 0.9,
258
+ });
259
+ });
260
+
261
+ // Should still have only one attachment with original progress
262
+ expect(result.current.attachments).toHaveLength(1);
263
+ expect(result.current.attachments[0]?.attachmentId).toBe("att_tracked");
264
+ });
265
+
266
+ it("updates error state on failed upload", async () => {
267
+ mockLaunchCameraAsync.mockResolvedValue({
268
+ canceled: false,
269
+ assets: [
270
+ {
271
+ uri: "file:///photo.jpg",
272
+ fileName: "photo.jpg",
273
+ mimeType: "image/jpeg",
274
+ fileSize: 1000,
275
+ },
276
+ ],
277
+ });
278
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_fail", queueItemId: "queue_1" });
279
+
280
+ const { result } = renderHook(() => useAttachmentUpload());
281
+
282
+ await act(async () => {
283
+ await result.current.pickImage("camera");
284
+ });
285
+
286
+ // Simulate failure
287
+ act(() => {
288
+ mockProgressCallback?.({
289
+ attachmentId: "att_fail",
290
+ phase: UploadPhase.FAILED,
291
+ progress: 0.3,
292
+ error: "Upload failed: network error",
293
+ });
294
+ });
295
+
296
+ await waitFor(() => {
297
+ expect(result.current.attachments[0]?.phase).toBe(UploadPhase.FAILED);
298
+ expect(result.current.attachments[0]?.error).toBe("Upload failed: network error");
299
+ });
300
+ });
301
+ });
302
+
303
+ describe("retryAttachment", () => {
304
+ it("calls service with correct attachment ID", async () => {
305
+ mockRetryItem.mockResolvedValue(undefined);
306
+
307
+ const { result } = renderHook(() => useAttachmentUpload());
308
+
309
+ await act(async () => {
310
+ await result.current.retryAttachment("att_retry_123");
311
+ });
312
+
313
+ expect(mockRetryItem).toHaveBeenCalledWith("att_retry_123");
314
+ });
315
+ });
316
+
317
+ describe("removeAttachment", () => {
318
+ it("cancels service item and removes from local state", async () => {
319
+ mockLaunchCameraAsync.mockResolvedValue({
320
+ canceled: false,
321
+ assets: [
322
+ {
323
+ uri: "file:///photo.jpg",
324
+ fileName: "photo.jpg",
325
+ mimeType: "image/jpeg",
326
+ fileSize: 1000,
327
+ },
328
+ ],
329
+ });
330
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_remove", queueItemId: "queue_1" });
331
+ mockCancelItem.mockResolvedValue(undefined);
332
+
333
+ const { result } = renderHook(() => useAttachmentUpload());
334
+
335
+ // Add attachment
336
+ await act(async () => {
337
+ await result.current.pickImage("camera");
338
+ });
339
+
340
+ expect(result.current.attachments).toHaveLength(1);
341
+
342
+ // Remove it
343
+ await act(async () => {
344
+ await result.current.removeAttachment("att_remove");
345
+ });
346
+
347
+ expect(mockCancelItem).toHaveBeenCalledWith("att_remove");
348
+ expect(result.current.attachments).toHaveLength(0);
349
+ });
350
+ });
351
+
352
+ describe("hasActiveUploads", () => {
353
+ it("returns true when uploads are in progress", async () => {
354
+ mockLaunchCameraAsync.mockResolvedValue({
355
+ canceled: false,
356
+ assets: [
357
+ {
358
+ uri: "file:///photo.jpg",
359
+ fileName: "photo.jpg",
360
+ mimeType: "image/jpeg",
361
+ fileSize: 1000,
362
+ },
363
+ ],
364
+ });
365
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_active", queueItemId: "queue_1" });
366
+
367
+ const { result } = renderHook(() => useAttachmentUpload());
368
+
369
+ await act(async () => {
370
+ await result.current.pickImage("camera");
371
+ });
372
+
373
+ // Initial state is QUEUED, which counts as active
374
+ expect(result.current.hasActiveUploads).toBe(true);
375
+
376
+ // Simulate completion
377
+ act(() => {
378
+ mockProgressCallback?.({
379
+ attachmentId: "att_active",
380
+ phase: UploadPhase.COMPLETED,
381
+ progress: 1,
382
+ });
383
+ });
384
+
385
+ await waitFor(() => {
386
+ expect(result.current.hasActiveUploads).toBe(false);
387
+ });
388
+ });
389
+ });
390
+
391
+ describe("getAttachmentIds", () => {
392
+ it("returns all attachment IDs", async () => {
393
+ mockLaunchCameraAsync.mockResolvedValueOnce({
394
+ canceled: false,
395
+ assets: [
396
+ {
397
+ uri: "file:///photo1.jpg",
398
+ fileName: "photo1.jpg",
399
+ mimeType: "image/jpeg",
400
+ fileSize: 1000,
401
+ },
402
+ ],
403
+ });
404
+ mockLaunchCameraAsync.mockResolvedValueOnce({
405
+ canceled: false,
406
+ assets: [
407
+ {
408
+ uri: "file:///photo2.jpg",
409
+ fileName: "photo2.jpg",
410
+ mimeType: "image/jpeg",
411
+ fileSize: 2000,
412
+ },
413
+ ],
414
+ });
415
+ mockEnqueue.mockResolvedValueOnce({ attachmentId: "att_1", queueItemId: "queue_1" });
416
+ mockEnqueue.mockResolvedValueOnce({ attachmentId: "att_2", queueItemId: "queue_2" });
417
+
418
+ const { result } = renderHook(() => useAttachmentUpload());
419
+
420
+ await act(async () => {
421
+ await result.current.pickImage("camera");
422
+ await result.current.pickImage("camera");
423
+ });
424
+
425
+ expect(result.current.getAttachmentIds()).toEqual(["att_1", "att_2"]);
426
+ });
427
+ });
428
+
429
+ describe("clearCompleted", () => {
430
+ it("removes completed attachments from local state", async () => {
431
+ mockLaunchCameraAsync.mockResolvedValue({
432
+ canceled: false,
433
+ assets: [
434
+ {
435
+ uri: "file:///photo.jpg",
436
+ fileName: "photo.jpg",
437
+ mimeType: "image/jpeg",
438
+ fileSize: 1000,
439
+ },
440
+ ],
441
+ });
442
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_completed", queueItemId: "queue_1" });
443
+
444
+ const { result } = renderHook(() => useAttachmentUpload());
445
+
446
+ await act(async () => {
447
+ await result.current.pickImage("camera");
448
+ });
449
+
450
+ // Simulate completion
451
+ act(() => {
452
+ mockProgressCallback?.({
453
+ attachmentId: "att_completed",
454
+ phase: UploadPhase.COMPLETED,
455
+ progress: 1,
456
+ });
457
+ });
458
+
459
+ await waitFor(() => {
460
+ expect(result.current.attachments[0]?.phase).toBe(UploadPhase.COMPLETED);
461
+ });
462
+
463
+ // Clear completed
464
+ act(() => {
465
+ result.current.clearCompleted();
466
+ });
467
+
468
+ expect(mockClearCompleted).toHaveBeenCalled();
469
+ expect(result.current.attachments).toHaveLength(0);
470
+ });
471
+
472
+ it("keeps non-completed attachments when clearing completed", async () => {
473
+ mockLaunchCameraAsync
474
+ .mockResolvedValueOnce({
475
+ canceled: false,
476
+ assets: [
477
+ {
478
+ uri: "file:///photo1.jpg",
479
+ fileName: "photo1.jpg",
480
+ mimeType: "image/jpeg",
481
+ fileSize: 1000,
482
+ },
483
+ ],
484
+ })
485
+ .mockResolvedValueOnce({
486
+ canceled: false,
487
+ assets: [
488
+ {
489
+ uri: "file:///photo2.jpg",
490
+ fileName: "photo2.jpg",
491
+ mimeType: "image/jpeg",
492
+ fileSize: 2000,
493
+ },
494
+ ],
495
+ });
496
+ mockEnqueue
497
+ .mockResolvedValueOnce({ attachmentId: "att_completed", queueItemId: "queue_1" })
498
+ .mockResolvedValueOnce({ attachmentId: "att_pending", queueItemId: "queue_2" });
499
+
500
+ const { result } = renderHook(() => useAttachmentUpload());
501
+
502
+ await act(async () => {
503
+ await result.current.pickImage("camera");
504
+ await result.current.pickImage("camera");
505
+ });
506
+
507
+ // Mark first as completed
508
+ act(() => {
509
+ mockProgressCallback?.({
510
+ attachmentId: "att_completed",
511
+ phase: UploadPhase.COMPLETED,
512
+ progress: 1,
513
+ });
514
+ });
515
+
516
+ await waitFor(() => {
517
+ expect(result.current.attachments).toHaveLength(2);
518
+ });
519
+
520
+ // Clear completed
521
+ act(() => {
522
+ result.current.clearCompleted();
523
+ });
524
+
525
+ expect(result.current.attachments).toHaveLength(1);
526
+ expect(result.current.attachments[0]?.attachmentId).toBe("att_pending");
527
+ });
528
+ });
529
+
530
+ describe("clearFailed", () => {
531
+ it("removes failed attachments from local state", async () => {
532
+ mockLaunchCameraAsync.mockResolvedValue({
533
+ canceled: false,
534
+ assets: [
535
+ {
536
+ uri: "file:///photo.jpg",
537
+ fileName: "photo.jpg",
538
+ mimeType: "image/jpeg",
539
+ fileSize: 1000,
540
+ },
541
+ ],
542
+ });
543
+ mockEnqueue.mockResolvedValue({ attachmentId: "att_failed", queueItemId: "queue_1" });
544
+
545
+ const { result } = renderHook(() => useAttachmentUpload());
546
+
547
+ await act(async () => {
548
+ await result.current.pickImage("camera");
549
+ });
550
+
551
+ // Simulate failure
552
+ act(() => {
553
+ mockProgressCallback?.({
554
+ attachmentId: "att_failed",
555
+ phase: UploadPhase.FAILED,
556
+ progress: 0,
557
+ error: "Upload failed",
558
+ });
559
+ });
560
+
561
+ await waitFor(() => {
562
+ expect(result.current.attachments[0]?.phase).toBe(UploadPhase.FAILED);
563
+ });
564
+
565
+ // Clear failed
566
+ act(() => {
567
+ result.current.clearFailed();
568
+ });
569
+
570
+ expect(mockClearFailed).toHaveBeenCalled();
571
+ expect(result.current.attachments).toHaveLength(0);
572
+ });
573
+
574
+ it("keeps non-failed attachments when clearing failed", async () => {
575
+ mockLaunchCameraAsync
576
+ .mockResolvedValueOnce({
577
+ canceled: false,
578
+ assets: [
579
+ {
580
+ uri: "file:///photo1.jpg",
581
+ fileName: "photo1.jpg",
582
+ mimeType: "image/jpeg",
583
+ fileSize: 1000,
584
+ },
585
+ ],
586
+ })
587
+ .mockResolvedValueOnce({
588
+ canceled: false,
589
+ assets: [
590
+ {
591
+ uri: "file:///photo2.jpg",
592
+ fileName: "photo2.jpg",
593
+ mimeType: "image/jpeg",
594
+ fileSize: 2000,
595
+ },
596
+ ],
597
+ });
598
+ mockEnqueue
599
+ .mockResolvedValueOnce({ attachmentId: "att_failed", queueItemId: "queue_1" })
600
+ .mockResolvedValueOnce({ attachmentId: "att_pending", queueItemId: "queue_2" });
601
+
602
+ const { result } = renderHook(() => useAttachmentUpload());
603
+
604
+ await act(async () => {
605
+ await result.current.pickImage("camera");
606
+ await result.current.pickImage("camera");
607
+ });
608
+
609
+ // Mark first as failed
610
+ act(() => {
611
+ mockProgressCallback?.({
612
+ attachmentId: "att_failed",
613
+ phase: UploadPhase.FAILED,
614
+ progress: 0,
615
+ error: "Upload failed",
616
+ });
617
+ });
618
+
619
+ await waitFor(() => {
620
+ expect(result.current.attachments).toHaveLength(2);
621
+ });
622
+
623
+ // Clear failed
624
+ act(() => {
625
+ result.current.clearFailed();
626
+ });
627
+
628
+ expect(result.current.attachments).toHaveLength(1);
629
+ expect(result.current.attachments[0]?.attachmentId).toBe("att_pending");
630
+ });
631
+ });
632
+ });