@tuturuuu/utils 0.0.3 → 0.6.1

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 (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,581 @@
1
+ import type { JSONContent } from '@tiptap/react';
2
+ import { Schema } from 'prosemirror-model';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ convertJsonContentToYjsState,
6
+ convertYjsStateToJsonContent,
7
+ } from '../yjs-helper';
8
+
9
+ // Create a basic ProseMirror schema for testing
10
+ const testSchema = new Schema({
11
+ nodes: {
12
+ doc: {
13
+ content: 'block+',
14
+ },
15
+ paragraph: {
16
+ content: 'inline*',
17
+ group: 'block',
18
+ parseDOM: [{ tag: 'p' }],
19
+ toDOM() {
20
+ return ['p', 0];
21
+ },
22
+ },
23
+ text: {
24
+ group: 'inline',
25
+ },
26
+ heading: {
27
+ attrs: { level: { default: 1 } },
28
+ content: 'inline*',
29
+ group: 'block',
30
+ parseDOM: [
31
+ { tag: 'h1', attrs: { level: 1 } },
32
+ { tag: 'h2', attrs: { level: 2 } },
33
+ { tag: 'h3', attrs: { level: 3 } },
34
+ ],
35
+ toDOM(node) {
36
+ return [`h${node.attrs.level}`, 0];
37
+ },
38
+ },
39
+ blockquote: {
40
+ content: 'block+',
41
+ group: 'block',
42
+ parseDOM: [{ tag: 'blockquote' }],
43
+ toDOM() {
44
+ return ['blockquote', 0];
45
+ },
46
+ },
47
+ },
48
+ marks: {
49
+ strong: {
50
+ parseDOM: [{ tag: 'strong' }, { tag: 'b' }],
51
+ toDOM() {
52
+ return ['strong', 0];
53
+ },
54
+ },
55
+ em: {
56
+ parseDOM: [{ tag: 'em' }, { tag: 'i' }],
57
+ toDOM() {
58
+ return ['em', 0];
59
+ },
60
+ },
61
+ },
62
+ });
63
+
64
+ describe('convertJsonContentToYjsState', () => {
65
+ it('converts simple paragraph JSONContent to Yjs state', () => {
66
+ const jsonContent: JSONContent = {
67
+ type: 'doc',
68
+ content: [
69
+ {
70
+ type: 'paragraph',
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: 'Hello, world!',
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ };
80
+
81
+ const yjsState = convertJsonContentToYjsState(jsonContent, testSchema);
82
+
83
+ expect(yjsState).toBeInstanceOf(Uint8Array);
84
+ expect(yjsState.length).toBeGreaterThan(0);
85
+ });
86
+
87
+ it('converts empty document to Yjs state', () => {
88
+ const jsonContent: JSONContent = {
89
+ type: 'doc',
90
+ content: [
91
+ {
92
+ type: 'paragraph',
93
+ },
94
+ ],
95
+ };
96
+
97
+ const yjsState = convertJsonContentToYjsState(jsonContent, testSchema);
98
+
99
+ expect(yjsState).toBeInstanceOf(Uint8Array);
100
+ expect(yjsState.length).toBeGreaterThan(0);
101
+ });
102
+
103
+ it('converts complex nested content to Yjs state', () => {
104
+ const jsonContent: JSONContent = {
105
+ type: 'doc',
106
+ content: [
107
+ {
108
+ type: 'heading',
109
+ attrs: { level: 1 },
110
+ content: [{ type: 'text', text: 'Title' }],
111
+ },
112
+ {
113
+ type: 'paragraph',
114
+ content: [
115
+ { type: 'text', text: 'This is ' },
116
+ { type: 'text', marks: [{ type: 'strong' }], text: 'bold' },
117
+ { type: 'text', text: ' and ' },
118
+ { type: 'text', marks: [{ type: 'em' }], text: 'italic' },
119
+ { type: 'text', text: ' text.' },
120
+ ],
121
+ },
122
+ {
123
+ type: 'blockquote',
124
+ content: [
125
+ {
126
+ type: 'paragraph',
127
+ content: [{ type: 'text', text: 'A quoted paragraph' }],
128
+ },
129
+ ],
130
+ },
131
+ ],
132
+ };
133
+
134
+ const yjsState = convertJsonContentToYjsState(jsonContent, testSchema);
135
+
136
+ expect(yjsState).toBeInstanceOf(Uint8Array);
137
+ expect(yjsState.length).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('converts document with multiple paragraphs', () => {
141
+ const jsonContent: JSONContent = {
142
+ type: 'doc',
143
+ content: [
144
+ {
145
+ type: 'paragraph',
146
+ content: [{ type: 'text', text: 'First paragraph' }],
147
+ },
148
+ {
149
+ type: 'paragraph',
150
+ content: [{ type: 'text', text: 'Second paragraph' }],
151
+ },
152
+ {
153
+ type: 'paragraph',
154
+ content: [{ type: 'text', text: 'Third paragraph' }],
155
+ },
156
+ ],
157
+ };
158
+
159
+ const yjsState = convertJsonContentToYjsState(jsonContent, testSchema);
160
+
161
+ expect(yjsState).toBeInstanceOf(Uint8Array);
162
+ expect(yjsState.length).toBeGreaterThan(0);
163
+ });
164
+
165
+ it('rejects JSONContent with unsupported node types when a schema is provided', () => {
166
+ const jsonContent: JSONContent = {
167
+ type: 'doc',
168
+ content: [
169
+ {
170
+ type: 'unsupportedNode',
171
+ content: [{ type: 'text', text: 'Invalid node' }],
172
+ },
173
+ ],
174
+ };
175
+
176
+ expect(() => convertJsonContentToYjsState(jsonContent, testSchema)).toThrow(
177
+ /Unknown node type/
178
+ );
179
+ });
180
+
181
+ it('rejects JSONContent with unsupported marks when a schema is provided', () => {
182
+ const jsonContent: JSONContent = {
183
+ type: 'doc',
184
+ content: [
185
+ {
186
+ type: 'paragraph',
187
+ content: [
188
+ {
189
+ marks: [{ type: 'unknownMark' }],
190
+ text: 'Invalid mark',
191
+ type: 'text',
192
+ },
193
+ ],
194
+ },
195
+ ],
196
+ };
197
+
198
+ expect(() => convertJsonContentToYjsState(jsonContent, testSchema)).toThrow(
199
+ /There is no mark type/
200
+ );
201
+ });
202
+ });
203
+
204
+ describe('convertYjsStateToJsonContent', () => {
205
+ it('converts Yjs state back to JSONContent', () => {
206
+ const originalJson: JSONContent = {
207
+ type: 'doc',
208
+ content: [
209
+ {
210
+ type: 'paragraph',
211
+ content: [
212
+ {
213
+ type: 'text',
214
+ text: 'Test content',
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ };
220
+
221
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
222
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
223
+
224
+ expect(resultJson).toBeDefined();
225
+ expect(resultJson.type).toBe('doc');
226
+ expect(resultJson.content).toBeDefined();
227
+ });
228
+
229
+ it('preserves text content through conversion', () => {
230
+ const originalJson: JSONContent = {
231
+ type: 'doc',
232
+ content: [
233
+ {
234
+ type: 'paragraph',
235
+ content: [{ type: 'text', text: 'Preserved text' }],
236
+ },
237
+ ],
238
+ };
239
+
240
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
241
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
242
+
243
+ expect(resultJson.content?.[0]).toBeDefined();
244
+ expect(resultJson.content?.[0]?.type).toBe('paragraph');
245
+ expect(resultJson.content?.[0]?.content?.[0]).toBeDefined();
246
+ expect(resultJson.content?.[0]?.content?.[0]?.text).toBe('Preserved text');
247
+ });
248
+
249
+ it('preserves marks through conversion', () => {
250
+ const originalJson: JSONContent = {
251
+ type: 'doc',
252
+ content: [
253
+ {
254
+ type: 'paragraph',
255
+ content: [
256
+ { type: 'text', marks: [{ type: 'strong' }], text: 'Bold text' },
257
+ ],
258
+ },
259
+ ],
260
+ };
261
+
262
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
263
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
264
+
265
+ const textNode = resultJson.content?.[0]?.content?.[0];
266
+ expect(textNode?.marks).toBeDefined();
267
+ expect(textNode?.marks?.[0]?.type).toBe('strong');
268
+ expect(textNode?.text).toBe('Bold text');
269
+ });
270
+
271
+ it('preserves node attributes through conversion', () => {
272
+ const originalJson: JSONContent = {
273
+ type: 'doc',
274
+ content: [
275
+ {
276
+ type: 'heading',
277
+ attrs: { level: 2 },
278
+ content: [{ type: 'text', text: 'Heading 2' }],
279
+ },
280
+ ],
281
+ };
282
+
283
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
284
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
285
+
286
+ const headingNode = resultJson.content?.[0];
287
+ expect(headingNode?.type).toBe('heading');
288
+ expect(headingNode?.attrs?.level).toBe(2);
289
+ expect(headingNode?.content?.[0]?.text).toBe('Heading 2');
290
+ });
291
+
292
+ it('rejects decoded Yjs content with unsupported node types when a schema is provided', () => {
293
+ const malformedState = convertJsonContentToYjsState({
294
+ type: 'doc',
295
+ content: [
296
+ {
297
+ type: 'unsupportedNode',
298
+ content: [{ type: 'text', text: 'Invalid node' }],
299
+ },
300
+ ],
301
+ });
302
+
303
+ expect(() =>
304
+ convertYjsStateToJsonContent(malformedState, testSchema)
305
+ ).toThrow(/Unknown node type/);
306
+ });
307
+ });
308
+
309
+ describe('round-trip conversion', () => {
310
+ it('preserves simple content through round-trip conversion', () => {
311
+ const originalJson: JSONContent = {
312
+ type: 'doc',
313
+ content: [
314
+ {
315
+ type: 'paragraph',
316
+ content: [{ type: 'text', text: 'Round trip test' }],
317
+ },
318
+ ],
319
+ };
320
+
321
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
322
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
323
+
324
+ expect(resultJson.type).toBe(originalJson.type);
325
+ expect(resultJson.content?.[0]?.type).toBe(originalJson.content?.[0]?.type);
326
+ expect(resultJson.content?.[0]?.content?.[0]?.text).toBe(
327
+ originalJson.content?.[0]?.content?.[0]?.text
328
+ );
329
+ });
330
+
331
+ it('preserves complex nested structure through round-trip', () => {
332
+ const originalJson: JSONContent = {
333
+ type: 'doc',
334
+ content: [
335
+ {
336
+ type: 'heading',
337
+ attrs: { level: 1 },
338
+ content: [{ type: 'text', text: 'Main Title' }],
339
+ },
340
+ {
341
+ type: 'paragraph',
342
+ content: [
343
+ { type: 'text', text: 'Regular text with ' },
344
+ { type: 'text', marks: [{ type: 'strong' }], text: 'bold' },
345
+ { type: 'text', text: ' and ' },
346
+ {
347
+ type: 'text',
348
+ marks: [{ type: 'em' }, { type: 'strong' }],
349
+ text: 'bold italic',
350
+ },
351
+ { type: 'text', text: '.' },
352
+ ],
353
+ },
354
+ {
355
+ type: 'blockquote',
356
+ content: [
357
+ {
358
+ type: 'paragraph',
359
+ content: [{ type: 'text', text: 'Quoted text' }],
360
+ },
361
+ ],
362
+ },
363
+ ],
364
+ };
365
+
366
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
367
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
368
+
369
+ // Verify structure
370
+ expect(resultJson.type).toBe('doc');
371
+ expect(resultJson.content?.length).toBe(3);
372
+
373
+ // Verify heading
374
+ const heading = resultJson.content?.[0];
375
+ expect(heading?.type).toBe('heading');
376
+ expect(heading?.attrs?.level).toBe(1);
377
+ expect(heading?.content?.[0]?.text).toBe('Main Title');
378
+
379
+ // Verify paragraph with marks
380
+ const paragraph = resultJson.content?.[1];
381
+ expect(paragraph?.type).toBe('paragraph');
382
+ expect(paragraph?.content?.length).toBe(5);
383
+
384
+ // Verify blockquote
385
+ const blockquote = resultJson.content?.[2];
386
+ expect(blockquote?.type).toBe('blockquote');
387
+ expect(blockquote?.content?.[0]?.type).toBe('paragraph');
388
+ expect(blockquote?.content?.[0]?.content?.[0]?.text).toBe('Quoted text');
389
+ });
390
+
391
+ it('preserves empty paragraphs through round-trip', () => {
392
+ const originalJson: JSONContent = {
393
+ type: 'doc',
394
+ content: [
395
+ {
396
+ type: 'paragraph',
397
+ },
398
+ {
399
+ type: 'paragraph',
400
+ content: [{ type: 'text', text: 'Content' }],
401
+ },
402
+ {
403
+ type: 'paragraph',
404
+ },
405
+ ],
406
+ };
407
+
408
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
409
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
410
+
411
+ expect(resultJson.content?.length).toBe(3);
412
+ expect(resultJson.content?.[0]?.type).toBe('paragraph');
413
+ expect(resultJson.content?.[1]?.content?.[0]?.text).toBe('Content');
414
+ expect(resultJson.content?.[2]?.type).toBe('paragraph');
415
+ });
416
+
417
+ it('handles multiple consecutive text nodes with different marks', () => {
418
+ const originalJson: JSONContent = {
419
+ type: 'doc',
420
+ content: [
421
+ {
422
+ type: 'paragraph',
423
+ content: [
424
+ { type: 'text', text: 'Normal ' },
425
+ { type: 'text', marks: [{ type: 'strong' }], text: 'bold ' },
426
+ { type: 'text', marks: [{ type: 'em' }], text: 'italic ' },
427
+ {
428
+ type: 'text',
429
+ marks: [{ type: 'strong' }, { type: 'em' }],
430
+ text: 'both',
431
+ },
432
+ ],
433
+ },
434
+ ],
435
+ };
436
+
437
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
438
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
439
+
440
+ const content = resultJson.content?.[0]?.content;
441
+ expect(content?.length).toBeGreaterThanOrEqual(4);
442
+
443
+ // Check that marks are preserved
444
+ const boldNode = content?.find(
445
+ (node) => node.marks?.length === 1 && node.marks[0]?.type === 'strong'
446
+ );
447
+ expect(boldNode).toBeDefined();
448
+
449
+ const italicNode = content?.find(
450
+ (node) => node.marks?.length === 1 && node.marks[0]?.type === 'em'
451
+ );
452
+ expect(italicNode).toBeDefined();
453
+
454
+ const bothNode = content?.find((node) => node.marks?.length === 2);
455
+ expect(bothNode).toBeDefined();
456
+ });
457
+
458
+ it('preserves document with only empty paragraph', () => {
459
+ const originalJson: JSONContent = {
460
+ type: 'doc',
461
+ content: [
462
+ {
463
+ type: 'paragraph',
464
+ },
465
+ ],
466
+ };
467
+
468
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
469
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
470
+
471
+ expect(resultJson.type).toBe('doc');
472
+ expect(resultJson.content?.length).toBe(1);
473
+ expect(resultJson.content?.[0]?.type).toBe('paragraph');
474
+ });
475
+ });
476
+
477
+ describe('edge cases', () => {
478
+ it('handles document with deeply nested blockquotes', () => {
479
+ const originalJson: JSONContent = {
480
+ type: 'doc',
481
+ content: [
482
+ {
483
+ type: 'blockquote',
484
+ content: [
485
+ {
486
+ type: 'blockquote',
487
+ content: [
488
+ {
489
+ type: 'paragraph',
490
+ content: [{ type: 'text', text: 'Nested quote' }],
491
+ },
492
+ ],
493
+ },
494
+ ],
495
+ },
496
+ ],
497
+ };
498
+
499
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
500
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
501
+
502
+ expect(resultJson.content?.[0]?.type).toBe('blockquote');
503
+ expect(resultJson.content?.[0]?.content?.[0]?.type).toBe('blockquote');
504
+ expect(resultJson.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe(
505
+ 'paragraph'
506
+ );
507
+ });
508
+
509
+ it('handles various heading levels', () => {
510
+ const originalJson: JSONContent = {
511
+ type: 'doc',
512
+ content: [
513
+ {
514
+ type: 'heading',
515
+ attrs: { level: 1 },
516
+ content: [{ type: 'text', text: 'H1' }],
517
+ },
518
+ {
519
+ type: 'heading',
520
+ attrs: { level: 2 },
521
+ content: [{ type: 'text', text: 'H2' }],
522
+ },
523
+ {
524
+ type: 'heading',
525
+ attrs: { level: 3 },
526
+ content: [{ type: 'text', text: 'H3' }],
527
+ },
528
+ ],
529
+ };
530
+
531
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
532
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
533
+
534
+ expect(resultJson.content?.[0]?.attrs?.level).toBe(1);
535
+ expect(resultJson.content?.[1]?.attrs?.level).toBe(2);
536
+ expect(resultJson.content?.[2]?.attrs?.level).toBe(3);
537
+ });
538
+
539
+ it('handles text with special characters', () => {
540
+ const originalJson: JSONContent = {
541
+ type: 'doc',
542
+ content: [
543
+ {
544
+ type: 'paragraph',
545
+ content: [
546
+ {
547
+ type: 'text',
548
+ text: 'Special chars: <>&"\'éñ中文🚀',
549
+ },
550
+ ],
551
+ },
552
+ ],
553
+ };
554
+
555
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
556
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
557
+
558
+ expect(resultJson.content?.[0]?.content?.[0]?.text).toBe(
559
+ 'Special chars: <>&"\'éñ中文🚀'
560
+ );
561
+ });
562
+
563
+ it('handles long text content', () => {
564
+ const longText = 'A'.repeat(10000);
565
+ const originalJson: JSONContent = {
566
+ type: 'doc',
567
+ content: [
568
+ {
569
+ type: 'paragraph',
570
+ content: [{ type: 'text', text: longText }],
571
+ },
572
+ ],
573
+ };
574
+
575
+ const yjsState = convertJsonContentToYjsState(originalJson, testSchema);
576
+ const resultJson = convertYjsStateToJsonContent(yjsState, testSchema);
577
+
578
+ expect(resultJson.content?.[0]?.content?.[0]?.text).toBe(longText);
579
+ expect(resultJson.content?.[0]?.content?.[0]?.text?.length).toBe(10000);
580
+ });
581
+ });
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ blockIPEdge: vi.fn(),
5
+ checkUserSuspension: vi.fn(),
6
+ suspendUser: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../edge', () => ({
10
+ blockIPEdge: (...args: Parameters<typeof mocks.blockIPEdge>) =>
11
+ mocks.blockIPEdge(...args),
12
+ }));
13
+
14
+ vi.mock('../user-suspension', () => ({
15
+ checkUserSuspension: (
16
+ ...args: Parameters<typeof mocks.checkUserSuspension>
17
+ ) => mocks.checkUserSuspension(...args),
18
+ suspendUser: (...args: Parameters<typeof mocks.suspendUser>) =>
19
+ mocks.suspendUser(...args),
20
+ }));
21
+
22
+ import {
23
+ cascadeBackendRateLimitToProxyBan,
24
+ isBackendRateLimitError,
25
+ } from '../backend-rate-limit';
26
+
27
+ describe('backend-rate-limit', () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ mocks.blockIPEdge.mockResolvedValue(null);
31
+ mocks.checkUserSuspension.mockResolvedValue({ suspended: false });
32
+ mocks.suspendUser.mockResolvedValue(true);
33
+ });
34
+
35
+ describe('isBackendRateLimitError', () => {
36
+ it('matches explicit 429 status values', () => {
37
+ expect(isBackendRateLimitError({ status: 429 })).toBe(true);
38
+ });
39
+
40
+ it('matches Supabase auth over-request codes', () => {
41
+ expect(isBackendRateLimitError({ code: 'over_request_rate_limit' })).toBe(
42
+ true
43
+ );
44
+ });
45
+
46
+ it('rejects unrelated errors', () => {
47
+ expect(
48
+ isBackendRateLimitError({ status: 401, message: 'Unauthorized' })
49
+ ).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('cascadeBackendRateLimitToProxyBan', () => {
54
+ it('blocks the IP without suspending an identified user', async () => {
55
+ const blockInfo = {
56
+ id: 'block-1',
57
+ blockLevel: 1,
58
+ reason: 'api_abuse',
59
+ blockedAt: new Date(),
60
+ expiresAt: new Date(Date.now() + 300_000),
61
+ };
62
+ mocks.blockIPEdge.mockResolvedValue(blockInfo);
63
+
64
+ const result = await cascadeBackendRateLimitToProxyBan({
65
+ endpoint: '/api/ai/chat/new',
66
+ ipAddress: '203.0.113.10',
67
+ source: 'database',
68
+ userId: 'user-1',
69
+ });
70
+
71
+ expect(result).toBe(blockInfo);
72
+ expect(mocks.blockIPEdge).toHaveBeenCalledWith(
73
+ '203.0.113.10',
74
+ 'api_abuse'
75
+ );
76
+ expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
77
+ expect(mocks.suspendUser).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('does not inspect existing user suspensions', async () => {
81
+ mocks.checkUserSuspension.mockResolvedValue({
82
+ suspended: true,
83
+ reason: 'already suspended',
84
+ });
85
+
86
+ await cascadeBackendRateLimitToProxyBan({
87
+ endpoint: '/api/ai/chat/new',
88
+ ipAddress: '203.0.113.10',
89
+ source: 'auth',
90
+ userId: 'user-1',
91
+ });
92
+
93
+ expect(mocks.suspendUser).not.toHaveBeenCalled();
94
+ expect(mocks.blockIPEdge).toHaveBeenCalledWith(
95
+ '203.0.113.10',
96
+ 'api_abuse'
97
+ );
98
+ expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('does not suspend the user when the client IP is unavailable', async () => {
102
+ await cascadeBackendRateLimitToProxyBan({
103
+ endpoint: '/api/ai/chat/new',
104
+ ipAddress: 'unknown',
105
+ source: 'database',
106
+ userId: 'user-1',
107
+ });
108
+
109
+ expect(mocks.blockIPEdge).not.toHaveBeenCalled();
110
+ expect(mocks.suspendUser).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+ });