decap-cms-backend-forgejo 3.4.0

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.
@@ -0,0 +1,530 @@
1
+ import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'decap-cms-lib-util';
2
+
3
+ import API from '../API';
4
+ import ForgejoImplementation from '../implementation';
5
+
6
+ jest.spyOn(console, 'error').mockImplementation(() => {});
7
+
8
+ describe('forgejo backend implementation', () => {
9
+ const config = {
10
+ backend: {
11
+ repo: 'owner/repo',
12
+ api_root: 'https://v14.next.forgejo.org/api/v1',
13
+ },
14
+ };
15
+
16
+ const createObjectURL = jest.fn();
17
+ global.URL = {
18
+ createObjectURL,
19
+ };
20
+
21
+ createObjectURL.mockReturnValue('displayURL');
22
+
23
+ beforeAll(() => {
24
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
25
+ });
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ });
30
+
31
+ afterAll(() => {
32
+ jest.restoreAllMocks();
33
+ });
34
+
35
+ describe('persistMedia', () => {
36
+ const persistFiles = jest.fn();
37
+ const mockAPI = {
38
+ persistFiles,
39
+ };
40
+
41
+ persistFiles.mockImplementation((_, files) => {
42
+ files.forEach((file, index) => {
43
+ file.sha = index;
44
+ });
45
+ });
46
+
47
+ it('should persist media file', async () => {
48
+ const forgejoImplementation = new ForgejoImplementation(config);
49
+ forgejoImplementation.api = mockAPI;
50
+
51
+ const mediaFile = {
52
+ fileObj: { size: 100, name: 'image.png' },
53
+ path: '/media/image.png',
54
+ };
55
+
56
+ expect.assertions(5);
57
+ await expect(
58
+ forgejoImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
59
+ ).resolves.toEqual({
60
+ id: 0,
61
+ name: 'image.png',
62
+ size: 100,
63
+ displayURL: 'displayURL',
64
+ path: 'media/image.png',
65
+ });
66
+
67
+ expect(persistFiles).toHaveBeenCalledTimes(1);
68
+ expect(persistFiles).toHaveBeenCalledWith([], [mediaFile], {
69
+ commitMessage: 'Persisting media',
70
+ });
71
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
72
+ expect(createObjectURL).toHaveBeenCalledWith(mediaFile.fileObj);
73
+ });
74
+
75
+ it('should log and throw error on "persistFiles" error', async () => {
76
+ const forgejoImplementation = new ForgejoImplementation(config);
77
+ forgejoImplementation.api = mockAPI;
78
+
79
+ const error = new Error('failed to persist files');
80
+ persistFiles.mockRejectedValue(error);
81
+
82
+ const mediaFile = {
83
+ fileObj: { size: 100 },
84
+ path: '/media/image.png',
85
+ };
86
+
87
+ expect.assertions(5);
88
+ await expect(
89
+ forgejoImplementation.persistMedia(mediaFile, { commitMessage: 'Persisting media' }),
90
+ ).rejects.toThrowError(error);
91
+
92
+ expect(persistFiles).toHaveBeenCalledTimes(1);
93
+ expect(createObjectURL).toHaveBeenCalledTimes(0);
94
+ expect(console.error).toHaveBeenCalledTimes(1);
95
+ expect(console.error).toHaveBeenCalledWith(error);
96
+ });
97
+ });
98
+
99
+ describe('entriesByFolder', () => {
100
+ const listFiles = jest.fn();
101
+ const readFile = jest.fn();
102
+ const readFileMetadata = jest.fn(() => Promise.resolve({ author: '', updatedOn: '' }));
103
+
104
+ const mockAPI = {
105
+ listFiles,
106
+ readFile,
107
+ readFileMetadata,
108
+ originRepoURL: 'originRepoURL',
109
+ };
110
+
111
+ it('should return entries and cursor', async () => {
112
+ const forgejoImplementation = new ForgejoImplementation(config);
113
+ forgejoImplementation.api = mockAPI;
114
+
115
+ const files = [];
116
+ const count = 1501;
117
+ for (let i = 0; i < count; i++) {
118
+ const id = `${i}`.padStart(`${count}`.length, '0');
119
+ files.push({
120
+ id,
121
+ path: `posts/post-${id}.md`,
122
+ });
123
+ }
124
+
125
+ listFiles.mockResolvedValue(files);
126
+ readFile.mockImplementation((_path, id) => Promise.resolve(`${id}`));
127
+
128
+ const expectedEntries = files
129
+ .slice(0, 20)
130
+ .map(({ id, path }) => ({ data: id, file: { path, id, author: '', updatedOn: '' } }));
131
+
132
+ const expectedCursor = Cursor.create({
133
+ actions: ['next', 'last'],
134
+ meta: { page: 1, count, pageSize: 20, pageCount: 76 },
135
+ data: { files },
136
+ });
137
+
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
139
+ expectedEntries[CURSOR_COMPATIBILITY_SYMBOL] = expectedCursor;
140
+
141
+ const result = await forgejoImplementation.entriesByFolder('posts', 'md', 1);
142
+
143
+ expect(result).toEqual(expectedEntries);
144
+ expect(listFiles).toHaveBeenCalledTimes(1);
145
+ expect(listFiles).toHaveBeenCalledWith('posts', { depth: 1, repoURL: 'originRepoURL' });
146
+ expect(readFile).toHaveBeenCalledTimes(20);
147
+ });
148
+ });
149
+
150
+ describe('traverseCursor', () => {
151
+ const listFiles = jest.fn();
152
+ const readFile = jest.fn((_path, id) => Promise.resolve(`${id}`));
153
+ const readFileMetadata = jest.fn(() => Promise.resolve({}));
154
+
155
+ const mockAPI = {
156
+ listFiles,
157
+ readFile,
158
+ originRepoURL: 'originRepoURL',
159
+ readFileMetadata,
160
+ };
161
+
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const files = [];
164
+ const count = 1501;
165
+ for (let i = 0; i < count; i++) {
166
+ const id = `${i}`.padStart(`${count}`.length, '0');
167
+ files.push({
168
+ id,
169
+ path: `posts/post-${id}.md`,
170
+ });
171
+ }
172
+
173
+ it('should handle next action', async () => {
174
+ const forgejoImplementation = new ForgejoImplementation(config);
175
+ forgejoImplementation.api = mockAPI;
176
+
177
+ const cursor = Cursor.create({
178
+ actions: ['next', 'last'],
179
+ meta: { page: 1, count, pageSize: 20, pageCount: 76 },
180
+ data: { files },
181
+ });
182
+
183
+ const expectedEntries = files
184
+ .slice(20, 40)
185
+ .map(({ id, path }) => ({ data: id, file: { path, id } }));
186
+
187
+ const expectedCursor = Cursor.create({
188
+ actions: ['prev', 'first', 'next', 'last'],
189
+ meta: { page: 2, count, pageSize: 20, pageCount: 76 },
190
+ data: { files },
191
+ });
192
+
193
+ const result = await forgejoImplementation.traverseCursor(cursor, 'next');
194
+
195
+ expect(result).toEqual({
196
+ entries: expectedEntries,
197
+ cursor: expectedCursor,
198
+ });
199
+ });
200
+
201
+ it('should handle prev action', async () => {
202
+ const forgejoImplementation = new ForgejoImplementation(config);
203
+ forgejoImplementation.api = mockAPI;
204
+
205
+ const cursor = Cursor.create({
206
+ actions: ['prev', 'first', 'next', 'last'],
207
+ meta: { page: 2, count, pageSize: 20, pageCount: 76 },
208
+ data: { files },
209
+ });
210
+
211
+ const expectedEntries = files
212
+ .slice(0, 20)
213
+ .map(({ id, path }) => ({ data: id, file: { path, id } }));
214
+
215
+ const expectedCursor = Cursor.create({
216
+ actions: ['next', 'last'],
217
+ meta: { page: 1, count, pageSize: 20, pageCount: 76 },
218
+ data: { files },
219
+ });
220
+
221
+ const result = await forgejoImplementation.traverseCursor(cursor, 'prev');
222
+
223
+ expect(result).toEqual({
224
+ entries: expectedEntries,
225
+ cursor: expectedCursor,
226
+ });
227
+ });
228
+
229
+ it('should handle last action', async () => {
230
+ const forgejoImplementation = new ForgejoImplementation(config);
231
+ forgejoImplementation.api = mockAPI;
232
+
233
+ const cursor = Cursor.create({
234
+ actions: ['next', 'last'],
235
+ meta: { page: 1, count, pageSize: 20, pageCount: 76 },
236
+ data: { files },
237
+ });
238
+
239
+ const expectedEntries = files
240
+ .slice(1500)
241
+ .map(({ id, path }) => ({ data: id, file: { path, id } }));
242
+
243
+ const expectedCursor = Cursor.create({
244
+ actions: ['prev', 'first'],
245
+ meta: { page: 76, count, pageSize: 20, pageCount: 76 },
246
+ data: { files },
247
+ });
248
+
249
+ const result = await forgejoImplementation.traverseCursor(cursor, 'last');
250
+
251
+ expect(result).toEqual({
252
+ entries: expectedEntries,
253
+ cursor: expectedCursor,
254
+ });
255
+ });
256
+
257
+ it('should handle first action', async () => {
258
+ const forgejoImplementation = new ForgejoImplementation(config);
259
+ forgejoImplementation.api = mockAPI;
260
+
261
+ const cursor = Cursor.create({
262
+ actions: ['prev', 'first'],
263
+ meta: { page: 76, count, pageSize: 20, pageCount: 76 },
264
+ data: { files },
265
+ });
266
+
267
+ const expectedEntries = files
268
+ .slice(0, 20)
269
+ .map(({ id, path }) => ({ data: id, file: { path, id } }));
270
+
271
+ const expectedCursor = Cursor.create({
272
+ actions: ['next', 'last'],
273
+ meta: { page: 1, count, pageSize: 20, pageCount: 76 },
274
+ data: { files },
275
+ });
276
+
277
+ const result = await forgejoImplementation.traverseCursor(cursor, 'first');
278
+
279
+ expect(result).toEqual({
280
+ entries: expectedEntries,
281
+ cursor: expectedCursor,
282
+ });
283
+ });
284
+ });
285
+
286
+ describe('editorial workflow', () => {
287
+ it('should list unpublished entries', async () => {
288
+ const forgejoImplementation = new ForgejoImplementation(config);
289
+ forgejoImplementation.api = {
290
+ listUnpublishedBranches: jest.fn().mockResolvedValue(['cms/branch1', 'cms/branch2']),
291
+ };
292
+
293
+ await expect(forgejoImplementation.unpublishedEntries()).resolves.toEqual([
294
+ 'branch1',
295
+ 'branch2',
296
+ ]);
297
+ });
298
+
299
+ it('should get unpublished entry', async () => {
300
+ const forgejoImplementation = new ForgejoImplementation(config);
301
+ forgejoImplementation.api = {
302
+ generateContentKey: jest.fn().mockReturnValue('collection/slug'),
303
+ retrieveUnpublishedEntryData: jest
304
+ .fn()
305
+ .mockResolvedValue({ slug: 'slug', status: 'draft' }),
306
+ };
307
+
308
+ await expect(
309
+ forgejoImplementation.unpublishedEntry({ collection: 'collection', slug: 'slug' }),
310
+ ).resolves.toEqual({
311
+ slug: 'slug',
312
+ status: 'draft',
313
+ });
314
+ expect(forgejoImplementation.api.retrieveUnpublishedEntryData).toHaveBeenCalledWith(
315
+ 'collection/slug',
316
+ );
317
+ });
318
+
319
+ it('should get unpublished entry data file', async () => {
320
+ const forgejoImplementation = new ForgejoImplementation(config);
321
+ forgejoImplementation.api = {
322
+ generateContentKey: jest.fn().mockReturnValue('collection/slug'),
323
+ readFile: jest.fn().mockResolvedValue('file-content'),
324
+ };
325
+
326
+ await expect(
327
+ forgejoImplementation.unpublishedEntryDataFile(
328
+ 'collection',
329
+ 'slug',
330
+ 'path/to/file',
331
+ 'sha-123',
332
+ ),
333
+ ).resolves.toEqual('file-content');
334
+ });
335
+
336
+ it('should get unpublished entry media file', async () => {
337
+ const forgejoImplementation = new ForgejoImplementation(config);
338
+ const blob = new Blob(['content']);
339
+ forgejoImplementation.api = {
340
+ generateContentKey: jest.fn().mockReturnValue('collection/slug'),
341
+ readFile: jest.fn().mockResolvedValue(blob),
342
+ };
343
+
344
+ const result = await forgejoImplementation.unpublishedEntryMediaFile(
345
+ 'collection',
346
+ 'slug',
347
+ 'path/to/image.png',
348
+ 'sha-456',
349
+ );
350
+
351
+ expect(result.name).toBe('image.png');
352
+ expect(result.file).toEqual(expect.any(File));
353
+ });
354
+ });
355
+
356
+ describe('authenticate', () => {
357
+ it('should include useOpenAuthoring in authenticated user data', async () => {
358
+ const forgejoImplementation = new ForgejoImplementation(
359
+ {
360
+ ...config,
361
+ backend: { ...config.backend, open_authoring: true },
362
+ },
363
+ { useWorkflow: true },
364
+ );
365
+
366
+ forgejoImplementation.repo = 'contributor/repo';
367
+ forgejoImplementation.useOpenAuthoring = true;
368
+
369
+ const userSpy = jest.spyOn(API.prototype, 'user').mockResolvedValue({
370
+ full_name: 'Test User',
371
+ login: 'contributor',
372
+ email: 'user@example.com',
373
+ });
374
+ const accessSpy = jest.spyOn(API.prototype, 'hasWriteAccess').mockResolvedValue(true);
375
+
376
+ await expect(forgejoImplementation.authenticate({ token: 'token' })).resolves.toEqual({
377
+ name: 'Test User',
378
+ login: 'contributor',
379
+ email: 'user@example.com',
380
+ avatar_url: undefined,
381
+ token: 'token',
382
+ useOpenAuthoring: true,
383
+ });
384
+
385
+ expect(userSpy).toHaveBeenCalledTimes(1);
386
+ expect(accessSpy).toHaveBeenCalledTimes(1);
387
+
388
+ userSpy.mockRestore();
389
+ accessSpy.mockRestore();
390
+ });
391
+ });
392
+
393
+ describe('entriesByFiles', () => {
394
+ it('should read file-based entries from the origin repo in open authoring', async () => {
395
+ const forgejoImplementation = new ForgejoImplementation(
396
+ {
397
+ ...config,
398
+ backend: { ...config.backend, open_authoring: true },
399
+ },
400
+ { useWorkflow: true },
401
+ );
402
+
403
+ const mockAPI = {
404
+ repoURL: 'repoURL',
405
+ originRepoURL: 'originRepoURL',
406
+ readFile: jest.fn().mockResolvedValue('file contents'),
407
+ readFileMetadata: jest.fn().mockResolvedValue({ author: '', updatedOn: '' }),
408
+ };
409
+
410
+ forgejoImplementation.api = mockAPI;
411
+ forgejoImplementation.useOpenAuthoring = true;
412
+
413
+ await expect(
414
+ forgejoImplementation.entriesByFiles([{ path: 'content/posts/post.md', id: 'sha-123' }]),
415
+ ).resolves.toEqual([
416
+ {
417
+ data: 'file contents',
418
+ file: {
419
+ path: 'content/posts/post.md',
420
+ id: 'sha-123',
421
+ author: '',
422
+ updatedOn: '',
423
+ },
424
+ },
425
+ ]);
426
+
427
+ expect(mockAPI.readFile).toHaveBeenCalledWith('content/posts/post.md', 'sha-123', {
428
+ repoURL: 'originRepoURL',
429
+ });
430
+ });
431
+ });
432
+
433
+ describe('open authoring', () => {
434
+ describe('authenticateWithFork', () => {
435
+ it('should use origin repo if user is maintainer', async () => {
436
+ const forgejoImplementation = new ForgejoImplementation(
437
+ {
438
+ ...config,
439
+ backend: { ...config.backend, open_authoring: true },
440
+ },
441
+ { useWorkflow: true },
442
+ );
443
+
444
+ forgejoImplementation.userIsOriginMaintainer = jest.fn().mockResolvedValue(true);
445
+ forgejoImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'user' });
446
+ forgejoImplementation.api = {
447
+ forkExists: jest.fn(),
448
+ mergeUpstream: jest.fn(),
449
+ createFork: jest.fn(),
450
+ };
451
+
452
+ await forgejoImplementation.authenticateWithFork({
453
+ userData: { token: 'token' },
454
+ getPermissionToFork: jest.fn(),
455
+ });
456
+
457
+ expect(forgejoImplementation.repo).toBe('owner/repo');
458
+ expect(forgejoImplementation.useOpenAuthoring).toBe(false);
459
+ });
460
+
461
+ it('should create fork if user is contributor', async () => {
462
+ const mockForkExists = jest.fn().mockResolvedValue(false);
463
+ const mockCreateFork = jest.fn().mockResolvedValue({ full_name: 'contributor/repo' });
464
+ const mockMergeUpstream = jest.fn();
465
+
466
+ const forgejoImplementation = new ForgejoImplementation(
467
+ {
468
+ ...config,
469
+ backend: { ...config.backend, open_authoring: true },
470
+ },
471
+ {
472
+ useWorkflow: true,
473
+ API: {
474
+ forkExists: mockForkExists,
475
+ createFork: mockCreateFork,
476
+ mergeUpstream: mockMergeUpstream,
477
+ },
478
+ },
479
+ );
480
+
481
+ forgejoImplementation.userIsOriginMaintainer = jest.fn().mockResolvedValue(false);
482
+ forgejoImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'contributor' });
483
+ forgejoImplementation.pollUntilForkExists = jest.fn().mockResolvedValue(undefined);
484
+
485
+ await forgejoImplementation.authenticateWithFork({
486
+ userData: { token: 'token' },
487
+ getPermissionToFork: jest.fn().mockResolvedValue(),
488
+ });
489
+
490
+ expect(forgejoImplementation.repo).toBe('contributor/repo');
491
+ expect(forgejoImplementation.useOpenAuthoring).toBe(true);
492
+ expect(mockCreateFork).toHaveBeenCalled();
493
+ });
494
+
495
+ it('should sync existing fork if one exists', async () => {
496
+ const mockForkExists = jest.fn().mockResolvedValue(true);
497
+ const mockMergeUpstream = jest.fn().mockResolvedValue(undefined);
498
+ const mockCreateFork = jest.fn();
499
+
500
+ const forgejoImplementation = new ForgejoImplementation(
501
+ {
502
+ ...config,
503
+ backend: { ...config.backend, open_authoring: true },
504
+ },
505
+ {
506
+ useWorkflow: true,
507
+ API: {
508
+ forkExists: mockForkExists,
509
+ mergeUpstream: mockMergeUpstream,
510
+ createFork: mockCreateFork,
511
+ },
512
+ },
513
+ );
514
+
515
+ forgejoImplementation.userIsOriginMaintainer = jest.fn().mockResolvedValue(false);
516
+ forgejoImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'contributor' });
517
+
518
+ await forgejoImplementation.authenticateWithFork({
519
+ userData: { token: 'token' },
520
+ getPermissionToFork: jest.fn(),
521
+ });
522
+
523
+ expect(forgejoImplementation.repo).toBe('contributor/repo');
524
+ expect(forgejoImplementation.useOpenAuthoring).toBe(true);
525
+ expect(mockMergeUpstream).toHaveBeenCalled();
526
+ expect(mockCreateFork).not.toHaveBeenCalled();
527
+ });
528
+ });
529
+ });
530
+ });