@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,515 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { HarkenClient, createHarkenClient } from "./client";
3
+ import { HarkenApiError, HarkenNetworkError } from "./errors";
4
+
5
+ // Mock fetch globally
6
+ const mockFetch = vi.fn();
7
+ vi.stubGlobal("fetch", mockFetch);
8
+
9
+ // Mock retry module to avoid timing complexities in client tests
10
+ vi.mock("./retry", async () => {
11
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
12
+ const original = await vi.importActual<typeof import("./retry")>("./retry");
13
+ return {
14
+ ...original,
15
+ withRetry: vi.fn((fn: () => Promise<unknown>) => fn()), // Execute immediately without retries
16
+ };
17
+ });
18
+
19
+ describe("HarkenClient", () => {
20
+ beforeEach(() => {
21
+ mockFetch.mockReset();
22
+ });
23
+
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ describe("constructor", () => {
29
+ it("creates client with required config", () => {
30
+ const client = new HarkenClient({
31
+ publishableKey: "pk_test_123",
32
+ });
33
+ expect(client).toBeInstanceOf(HarkenClient);
34
+ });
35
+
36
+ it("uses default base URL when not provided", async () => {
37
+ const client = new HarkenClient({
38
+ publishableKey: "pk_test_123",
39
+ });
40
+
41
+ mockFetch.mockResolvedValueOnce({
42
+ ok: true,
43
+ json: () => Promise.resolve({ id: "feedback_123" }),
44
+ });
45
+
46
+ await client.submitFeedback({
47
+ message: "Test",
48
+ category: "bug",
49
+ anon_id: "anon_123",
50
+ });
51
+
52
+ expect(mockFetch).toHaveBeenCalledWith(
53
+ "https://api.harken.app/v1/feedback",
54
+ expect.any(Object)
55
+ );
56
+ });
57
+
58
+ it("uses custom base URL when provided", async () => {
59
+ const client = new HarkenClient({
60
+ publishableKey: "pk_test_123",
61
+ baseUrl: "https://custom.api.com",
62
+ });
63
+
64
+ mockFetch.mockResolvedValueOnce({
65
+ ok: true,
66
+ json: () => Promise.resolve({ id: "feedback_123" }),
67
+ });
68
+
69
+ await client.submitFeedback({
70
+ message: "Test",
71
+ category: "bug",
72
+ anon_id: "anon_123",
73
+ });
74
+
75
+ expect(mockFetch).toHaveBeenCalledWith(
76
+ "https://custom.api.com/v1/feedback",
77
+ expect.any(Object)
78
+ );
79
+ });
80
+ });
81
+
82
+ describe("request headers", () => {
83
+ it("includes Content-Type and X-Publishable-Key headers", async () => {
84
+ const client = new HarkenClient({
85
+ publishableKey: "pk_test_123",
86
+ });
87
+
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ json: () => Promise.resolve({ id: "feedback_123" }),
91
+ });
92
+
93
+ await client.submitFeedback({
94
+ message: "Test",
95
+ category: "bug",
96
+ anon_id: "anon_123",
97
+ });
98
+
99
+ const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
100
+ expect(options.headers).toMatchObject({
101
+ "Content-Type": "application/json",
102
+ "X-Publishable-Key": "pk_test_123",
103
+ });
104
+ });
105
+
106
+ it("includes X-User-Token header when userToken is set", async () => {
107
+ const client = new HarkenClient({
108
+ publishableKey: "pk_test_123",
109
+ userToken: "user_token_abc",
110
+ });
111
+
112
+ mockFetch.mockResolvedValueOnce({
113
+ ok: true,
114
+ json: () => Promise.resolve({ id: "feedback_123" }),
115
+ });
116
+
117
+ await client.submitFeedback({
118
+ message: "Test",
119
+ category: "bug",
120
+ anon_id: "anon_123",
121
+ });
122
+
123
+ const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
124
+ expect(options.headers).toMatchObject({
125
+ "X-User-Token": "user_token_abc",
126
+ });
127
+ });
128
+
129
+ it("does not include X-User-Token when not set", async () => {
130
+ const client = new HarkenClient({
131
+ publishableKey: "pk_test_123",
132
+ });
133
+
134
+ mockFetch.mockResolvedValueOnce({
135
+ ok: true,
136
+ json: () => Promise.resolve({ id: "feedback_123" }),
137
+ });
138
+
139
+ await client.submitFeedback({
140
+ message: "Test",
141
+ category: "bug",
142
+ anon_id: "anon_123",
143
+ });
144
+
145
+ const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
146
+ expect((options.headers as Record<string, string>)["X-User-Token"]).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ describe("submitFeedback", () => {
151
+ it("sends correct payload", async () => {
152
+ const client = new HarkenClient({
153
+ publishableKey: "pk_test_123",
154
+ });
155
+
156
+ mockFetch.mockResolvedValueOnce({
157
+ ok: true,
158
+ json: () => Promise.resolve({ id: "feedback_123" }),
159
+ });
160
+
161
+ await client.submitFeedback({
162
+ message: "This is feedback",
163
+ category: "idea",
164
+ anon_id: "anon_456",
165
+ metadata: { app_version: "1.0.0", platform: "ios" },
166
+ attachments: ["att_1", "att_2"],
167
+ });
168
+
169
+ const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
170
+ expect(JSON.parse(options.body as string)).toEqual({
171
+ message: "This is feedback",
172
+ category: "idea",
173
+ anon_id: "anon_456",
174
+ metadata: { app_version: "1.0.0", platform: "ios" },
175
+ attachments: ["att_1", "att_2"],
176
+ });
177
+ });
178
+
179
+ it("returns response on success", async () => {
180
+ const client = new HarkenClient({
181
+ publishableKey: "pk_test_123",
182
+ });
183
+
184
+ mockFetch.mockResolvedValueOnce({
185
+ ok: true,
186
+ json: () =>
187
+ Promise.resolve({
188
+ id: "feedback_123",
189
+ created_at: "2024-01-01T00:00:00Z",
190
+ }),
191
+ });
192
+
193
+ const result = await client.submitFeedback({
194
+ message: "Test",
195
+ category: "bug",
196
+ anon_id: "anon_123",
197
+ });
198
+
199
+ expect(result).toEqual({
200
+ id: "feedback_123",
201
+ created_at: "2024-01-01T00:00:00Z",
202
+ });
203
+ });
204
+ });
205
+
206
+ describe("createAttachmentUpload", () => {
207
+ it("sends correct payload and returns presigned URL", async () => {
208
+ const client = new HarkenClient({
209
+ publishableKey: "pk_test_123",
210
+ });
211
+
212
+ mockFetch.mockResolvedValueOnce({
213
+ ok: true,
214
+ json: () =>
215
+ Promise.resolve({
216
+ attachment_id: "att_123",
217
+ upload_url: "https://storage.example.com/upload",
218
+ expires_at: "2024-01-01T01:00:00Z",
219
+ }),
220
+ });
221
+
222
+ const result = await client.createAttachmentUpload({
223
+ filename: "screenshot.png",
224
+ content_type: "image/png",
225
+ size: 12345,
226
+ });
227
+
228
+ expect(mockFetch).toHaveBeenCalledWith(
229
+ expect.stringContaining("/v1/feedback/attachments/presign"),
230
+ expect.any(Object)
231
+ );
232
+ expect(result).toEqual({
233
+ attachment_id: "att_123",
234
+ upload_url: "https://storage.example.com/upload",
235
+ expires_at: "2024-01-01T01:00:00Z",
236
+ });
237
+ });
238
+ });
239
+
240
+ describe("confirmAttachment", () => {
241
+ it("confirms attachment upload", async () => {
242
+ const client = new HarkenClient({
243
+ publishableKey: "pk_test_123",
244
+ });
245
+
246
+ mockFetch.mockResolvedValueOnce({
247
+ ok: true,
248
+ json: () =>
249
+ Promise.resolve({
250
+ id: "att_123",
251
+ status: "confirmed",
252
+ }),
253
+ });
254
+
255
+ const result = await client.confirmAttachment("att_123");
256
+
257
+ expect(mockFetch).toHaveBeenCalledWith(
258
+ expect.stringContaining("/v1/feedback/attachments/att_123/confirm"),
259
+ expect.objectContaining({ method: "POST" })
260
+ );
261
+ expect(result).toEqual({
262
+ id: "att_123",
263
+ status: "confirmed",
264
+ });
265
+ });
266
+
267
+ it("includes bytes_uploaded in confirm request when provided", async () => {
268
+ const client = new HarkenClient({
269
+ publishableKey: "pk_test_123",
270
+ });
271
+
272
+ mockFetch.mockResolvedValueOnce({
273
+ ok: true,
274
+ json: () => Promise.resolve({ id: "att_123", status: "confirmed" }),
275
+ });
276
+
277
+ await client.confirmAttachment("att_123", { bytes_uploaded: 12345 });
278
+
279
+ const [, options] = mockFetch.mock.calls[0] as [string, RequestInit];
280
+ expect(JSON.parse(options.body as string)).toEqual({
281
+ bytes_uploaded: 12345,
282
+ });
283
+ });
284
+ });
285
+
286
+ describe("reportAttachmentFailure", () => {
287
+ it("reports attachment failure with error message", async () => {
288
+ const client = new HarkenClient({
289
+ publishableKey: "pk_test_123",
290
+ });
291
+
292
+ mockFetch.mockResolvedValueOnce({
293
+ ok: true,
294
+ json: () => Promise.resolve({ id: "att_123", status: "failed" }),
295
+ });
296
+
297
+ const result = await client.reportAttachmentFailure("att_123", "Upload timed out");
298
+
299
+ expect(mockFetch).toHaveBeenCalledWith(
300
+ expect.stringContaining("/v1/feedback/attachments/att_123/fail"),
301
+ expect.objectContaining({ method: "POST" })
302
+ );
303
+ expect(result.status).toBe("failed");
304
+ });
305
+ });
306
+
307
+ describe("getAttachmentStatus", () => {
308
+ it("retrieves attachment status", async () => {
309
+ const client = new HarkenClient({
310
+ publishableKey: "pk_test_123",
311
+ });
312
+
313
+ mockFetch.mockResolvedValueOnce({
314
+ ok: true,
315
+ json: () =>
316
+ Promise.resolve({
317
+ id: "att_123",
318
+ status: "confirmed",
319
+ download_url: "https://storage.example.com/download",
320
+ }),
321
+ });
322
+
323
+ const result = await client.getAttachmentStatus("att_123");
324
+
325
+ expect(mockFetch).toHaveBeenCalledWith(
326
+ expect.stringContaining("/v1/feedback/attachments/att_123"),
327
+ expect.any(Object)
328
+ );
329
+ expect(result).toEqual({
330
+ id: "att_123",
331
+ status: "confirmed",
332
+ download_url: "https://storage.example.com/download",
333
+ });
334
+ });
335
+ });
336
+
337
+ describe("error handling", () => {
338
+ it("throws HarkenApiError for non-OK responses", async () => {
339
+ const client = new HarkenClient({
340
+ publishableKey: "pk_test_123",
341
+ });
342
+
343
+ mockFetch.mockResolvedValueOnce({
344
+ ok: false,
345
+ status: 400,
346
+ statusText: "Bad Request",
347
+ headers: new Map(),
348
+ json: () =>
349
+ Promise.resolve({
350
+ error: {
351
+ code: "validation_error",
352
+ message: "Message is required",
353
+ details: [{ field: "message", message: "Required" }],
354
+ },
355
+ }),
356
+ });
357
+
358
+ await expect(
359
+ client.submitFeedback({
360
+ message: "",
361
+ category: "bug",
362
+ anon_id: "anon_123",
363
+ })
364
+ ).rejects.toThrow(HarkenApiError);
365
+ });
366
+
367
+ it("includes error code and details from API response", async () => {
368
+ const client = new HarkenClient({
369
+ publishableKey: "pk_test_123",
370
+ });
371
+
372
+ mockFetch.mockResolvedValueOnce({
373
+ ok: false,
374
+ status: 400,
375
+ statusText: "Bad Request",
376
+ headers: new Map(),
377
+ json: () =>
378
+ Promise.resolve({
379
+ error: {
380
+ code: "validation_error",
381
+ message: "Invalid category",
382
+ details: [{ field: "category", message: "Must be one of: bug, idea, ux, other" }],
383
+ },
384
+ }),
385
+ });
386
+
387
+ try {
388
+ await client.submitFeedback({
389
+ message: "Test",
390
+ category: "invalid" as "bug",
391
+ anon_id: "anon_123",
392
+ });
393
+ expect.fail("Should have thrown");
394
+ } catch (error) {
395
+ expect(error).toBeInstanceOf(HarkenApiError);
396
+ const apiError = error as HarkenApiError;
397
+ expect(apiError.code).toBe("validation_error");
398
+ expect(apiError.status).toBe(400);
399
+ expect(apiError.details).toHaveLength(1);
400
+ }
401
+ });
402
+
403
+ it("parses Retry-After header for 429 responses", async () => {
404
+ const client = new HarkenClient({
405
+ publishableKey: "pk_test_123",
406
+ });
407
+
408
+ const headers = new Headers();
409
+ headers.set("Retry-After", "30");
410
+
411
+ mockFetch.mockResolvedValueOnce({
412
+ ok: false,
413
+ status: 429,
414
+ statusText: "Too Many Requests",
415
+ headers,
416
+ json: () =>
417
+ Promise.resolve({
418
+ error: {
419
+ code: "rate_limited",
420
+ message: "Too many requests",
421
+ },
422
+ }),
423
+ });
424
+
425
+ try {
426
+ await client.submitFeedback({
427
+ message: "Test",
428
+ category: "bug",
429
+ anon_id: "anon_123",
430
+ });
431
+ expect.fail("Should have thrown");
432
+ } catch (error) {
433
+ expect(error).toBeInstanceOf(HarkenApiError);
434
+ const apiError = error as HarkenApiError;
435
+ expect(apiError.status).toBe(429);
436
+ expect(apiError.retryAfter).toBe(30);
437
+ }
438
+ });
439
+
440
+ it("handles non-JSON error responses gracefully", async () => {
441
+ const client = new HarkenClient({
442
+ publishableKey: "pk_test_123",
443
+ });
444
+
445
+ mockFetch.mockResolvedValueOnce({
446
+ ok: false,
447
+ status: 502,
448
+ statusText: "Bad Gateway",
449
+ headers: new Map(),
450
+ json: () => Promise.reject(new Error("Invalid JSON")),
451
+ });
452
+
453
+ try {
454
+ await client.submitFeedback({
455
+ message: "Test",
456
+ category: "bug",
457
+ anon_id: "anon_123",
458
+ });
459
+ expect.fail("Should have thrown");
460
+ } catch (error) {
461
+ expect(error).toBeInstanceOf(HarkenApiError);
462
+ const apiError = error as HarkenApiError;
463
+ expect(apiError.status).toBe(502);
464
+ expect(apiError.code).toBe("http_502");
465
+ }
466
+ });
467
+
468
+ it("throws HarkenNetworkError for fetch TypeError", async () => {
469
+ const client = new HarkenClient({
470
+ publishableKey: "pk_test_123",
471
+ });
472
+
473
+ mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch"));
474
+
475
+ await expect(
476
+ client.submitFeedback({
477
+ message: "Test",
478
+ category: "bug",
479
+ anon_id: "anon_123",
480
+ })
481
+ ).rejects.toThrow(HarkenNetworkError);
482
+ });
483
+
484
+ it("throws HarkenNetworkError for timeout (AbortError)", async () => {
485
+ const client = new HarkenClient({
486
+ publishableKey: "pk_test_123",
487
+ timeout: 100,
488
+ });
489
+
490
+ vi.useFakeTimers();
491
+
492
+ const abortError = new Error("Aborted");
493
+ abortError.name = "AbortError";
494
+ mockFetch.mockRejectedValueOnce(abortError);
495
+
496
+ const promise = client.submitFeedback({
497
+ message: "Test",
498
+ category: "bug",
499
+ anon_id: "anon_123",
500
+ });
501
+
502
+ await expect(promise).rejects.toThrow(HarkenNetworkError);
503
+ await expect(promise).rejects.toThrow("Request timed out");
504
+ });
505
+ });
506
+ });
507
+
508
+ describe("createHarkenClient", () => {
509
+ it("creates a HarkenClient instance", () => {
510
+ const client = createHarkenClient({
511
+ publishableKey: "pk_test_123",
512
+ });
513
+ expect(client).toBeInstanceOf(HarkenClient);
514
+ });
515
+ });