@nextsparkjs/theme-default 0.1.0-beta.92 → 0.1.0-beta.95

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.
@@ -21,7 +21,7 @@ const heroDesignFields: FieldDefinition[] = [
21
21
  {
22
22
  name: 'backgroundImage',
23
23
  label: 'Background Image',
24
- type: 'image',
24
+ type: 'media-library',
25
25
  tab: 'design',
26
26
  required: false,
27
27
  helpText: 'Optional background image (recommended: 1920x1080px minimum)',
@@ -34,7 +34,7 @@ const heroWithFormContentFields: FieldDefinition[] = [
34
34
  {
35
35
  name: 'backgroundImage',
36
36
  label: 'Background Image',
37
- type: 'image',
37
+ type: 'media-library',
38
38
  tab: 'content',
39
39
  required: true,
40
40
  helpText: 'Full-width background image (recommended: 1920x1080px minimum)',
@@ -148,7 +148,7 @@ const jumbotronDesignFields: FieldDefinition[] = [
148
148
  {
149
149
  name: 'backgroundImage',
150
150
  label: 'Background Image',
151
- type: 'image',
151
+ type: 'media-library',
152
152
  tab: 'design',
153
153
  required: false,
154
154
  helpText: 'Optional background image (recommended: 1920x1080px minimum)',
@@ -30,7 +30,7 @@ const logoCloudContentFields: FieldDefinition[] = [
30
30
  {
31
31
  name: 'image',
32
32
  label: 'Logo Image',
33
- type: 'image',
33
+ type: 'media-library',
34
34
  tab: 'content',
35
35
  required: true,
36
36
  helpText: 'Logo image URL (recommended: transparent PNG, 200x100px)',
@@ -46,7 +46,7 @@ const splitContentFields: FieldDefinition[] = [
46
46
  {
47
47
  name: 'image',
48
48
  label: 'Image',
49
- type: 'image',
49
+ type: 'media-library',
50
50
  tab: 'content',
51
51
  required: true,
52
52
  helpText: 'Featured image (recommended: 800x600px minimum)',
@@ -58,7 +58,7 @@ const testimonialsContentFields: FieldDefinition[] = [
58
58
  {
59
59
  name: 'avatar',
60
60
  label: 'Avatar Image',
61
- type: 'image',
61
+ type: 'media-library',
62
62
  tab: 'content',
63
63
  required: false,
64
64
  description: 'Profile picture of the person',
@@ -28,7 +28,7 @@ const videoHeroContentFields: FieldDefinition[] = [
28
28
  {
29
29
  name: 'videoThumbnail',
30
30
  label: 'Custom Thumbnail',
31
- type: 'image',
31
+ type: 'media-library',
32
32
  tab: 'content',
33
33
  required: false,
34
34
  helpText: 'Optional custom thumbnail shown before video plays (recommended: 1920x1080px)',
@@ -333,6 +333,23 @@ export const APP_CONFIG_OVERRIDES = {
333
333
  },
334
334
  },
335
335
 
336
+ // =============================================================================
337
+ // MEDIA LIBRARY OVERRIDES
338
+ // =============================================================================
339
+ // Uncomment and modify to customize media upload limits and accepted file types.
340
+ //
341
+ // media: {
342
+ // maxSizeMB: 10, // General fallback max size
343
+ // maxSizeImageMB: 10, // Max size for image/* files (overrides maxSizeMB)
344
+ // maxSizeVideoMB: 50, // Max size for video/* files (overrides maxSizeMB)
345
+ // acceptedTypes: ['image/*', 'video/*', 'application/pdf'],
346
+ // allowedMimeTypes: [
347
+ // 'image/jpeg', 'image/png', 'image/webp',
348
+ // 'video/mp4', 'video/webm',
349
+ // 'application/pdf',
350
+ // ],
351
+ // },
352
+
336
353
  // =============================================================================
337
354
  // DEV KEYRING - MOVED TO dev.config.ts
338
355
  // =============================================================================
@@ -4,5 +4,6 @@
4
4
  "support": "Support",
5
5
  "docs": "Documentation",
6
6
  "dashboard": "Dashboard",
7
- "pages": "Pages"
7
+ "pages": "Pages",
8
+ "media": "Media"
8
9
  }
@@ -4,5 +4,6 @@
4
4
  "support": "Soporte",
5
5
  "docs": "Documentación",
6
6
  "dashboard": "Panel",
7
- "pages": "Páginas"
7
+ "pages": "Páginas",
8
+ "media": "Multimedia"
8
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/theme-default",
3
- "version": "0.1.0-beta.92",
3
+ "version": "0.1.0-beta.95",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./config/theme.config.ts",
@@ -24,4 +24,4 @@
24
24
  "type": "theme",
25
25
  "name": "default"
26
26
  }
27
- }
27
+ }
@@ -12,6 +12,20 @@
12
12
  IMPORTS
13
13
  ============================================= */
14
14
  @import "tailwindcss";
15
+
16
+ /* =============================================
17
+ TAILWIND v4 DARK MODE CONFIGURATION
18
+
19
+ Por defecto Tailwind v4 usa @media (prefers-color-scheme: dark)
20
+ para las variantes dark:. Esto causa problemas porque:
21
+ - next-themes usa la clase .dark en el HTML
22
+ - El sistema operativo puede tener dark mode aunque la app use light
23
+
24
+ Esta configuración hace que Tailwind use el selector de clase .dark
25
+ en lugar de la media query, sincronizándose con next-themes.
26
+ ============================================= */
27
+ @custom-variant dark (&:where(.dark, .dark *));
28
+
15
29
  @plugin "@tailwindcss/container-queries";
16
30
  @import "@nextsparkjs/core/styles/ui.css";
17
31
  @import "@nextsparkjs/core/styles/utilities.css";
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Media API - CRUD Tests
3
+ *
4
+ * Comprehensive test suite for Media API endpoints.
5
+ * Tests GET, PATCH, DELETE operations and upload functionality.
6
+ *
7
+ * Entity characteristics:
8
+ * - No team context required (global media library)
9
+ * - Soft delete (status → deleted)
10
+ * - Supports filtering by type, search by filename
11
+ * - Pagination via limit/offset
12
+ * - Sorting via orderBy/orderDir
13
+ */
14
+
15
+ /// <reference types="cypress" />
16
+
17
+ import * as allure from 'allure-cypress'
18
+
19
+ const MediaAPIController = require('../../../../src/controllers/MediaAPIController.js')
20
+
21
+ describe('Media API - CRUD Operations', {
22
+ tags: ['@api', '@feat-media-library', '@crud', '@regression']
23
+ }, () => {
24
+ // Test constants
25
+ const SUPERADMIN_API_KEY = 'test_api_key_for_testing_purposes_only_not_a_real_secret_key_abc123'
26
+ const BASE_URL = Cypress.config('baseUrl') || 'http://localhost:5173'
27
+
28
+ // Controller instance
29
+ let mediaAPI: InstanceType<typeof MediaAPIController>
30
+
31
+ // Track created media for cleanup
32
+ let createdMedia: any[] = []
33
+
34
+ before(() => {
35
+ // Initialize controller with superadmin credentials
36
+ mediaAPI = new MediaAPIController(BASE_URL, SUPERADMIN_API_KEY)
37
+ })
38
+
39
+ beforeEach(() => {
40
+ allure.epic('API')
41
+ allure.feature('Media Library')
42
+ })
43
+
44
+ afterEach(() => {
45
+ // Cleanup created media after each test
46
+ createdMedia.forEach((media) => {
47
+ if (media?.id) {
48
+ mediaAPI.deleteMedia(media.id)
49
+ }
50
+ })
51
+ createdMedia = []
52
+ })
53
+
54
+ // ============================================
55
+ // GET /api/v1/media - List Media
56
+ // ============================================
57
+ describe('GET /api/v1/media - List Media', () => {
58
+ it('MEDIA_API_001: Should list media with valid API key', { tags: '@smoke' }, () => {
59
+ allure.story('CRUD Operations')
60
+ allure.severity('critical')
61
+
62
+ mediaAPI.getMedia().then((response: any) => {
63
+ mediaAPI.validateSuccessResponse(response, 200)
64
+ expect(response.body.data).to.be.an('array')
65
+ expect(response.body.info).to.have.property('limit')
66
+ expect(response.body.info).to.have.property('offset')
67
+ expect(response.body.info).to.have.property('total')
68
+
69
+ cy.log(`Found ${response.body.data.length} media items`)
70
+ })
71
+ })
72
+
73
+ it('MEDIA_API_002: Should list media with pagination (limit/offset)', () => {
74
+ mediaAPI.getMedia({ limit: 5, offset: 0 }).then((response: any) => {
75
+ mediaAPI.validateSuccessResponse(response, 200)
76
+ expect(response.body.info.limit).to.eq(5)
77
+ expect(response.body.info.offset).to.eq(0)
78
+ expect(response.body.data.length).to.be.at.most(5)
79
+
80
+ cy.log(`Page 1 with limit 5: ${response.body.data.length} media items`)
81
+ })
82
+ })
83
+
84
+ it('MEDIA_API_003: Should filter media by type=image', () => {
85
+ mediaAPI.getMedia({ type: 'image' }).then((response: any) => {
86
+ mediaAPI.validateSuccessResponse(response, 200)
87
+ expect(response.body.data).to.be.an('array')
88
+
89
+ // All returned items should have type=image
90
+ response.body.data.forEach((media: any) => {
91
+ expect(media.type).to.eq('image')
92
+ })
93
+
94
+ cy.log(`Found ${response.body.data.length} images`)
95
+ })
96
+ })
97
+
98
+ it('MEDIA_API_004: Should filter media by type=video', () => {
99
+ mediaAPI.getMedia({ type: 'video' }).then((response: any) => {
100
+ mediaAPI.validateSuccessResponse(response, 200)
101
+ expect(response.body.data).to.be.an('array')
102
+
103
+ // All returned items should have type=video
104
+ response.body.data.forEach((media: any) => {
105
+ expect(media.type).to.eq('video')
106
+ })
107
+
108
+ cy.log(`Found ${response.body.data.length} videos`)
109
+ })
110
+ })
111
+
112
+ it('MEDIA_API_005: Should search media by filename', () => {
113
+ // This test assumes there's existing media with searchable filenames
114
+ // If no media exists, search returns empty array (still valid)
115
+ const searchTerm = 'test'
116
+
117
+ mediaAPI.getMedia({ search: searchTerm }).then((response: any) => {
118
+ mediaAPI.validateSuccessResponse(response, 200)
119
+ expect(response.body.data).to.be.an('array')
120
+
121
+ cy.log(`Search '${searchTerm}' found ${response.body.data.length} results`)
122
+ })
123
+ })
124
+
125
+ it('MEDIA_API_006: Should sort media by filename (ascending)', () => {
126
+ mediaAPI.getMedia({ orderBy: 'filename', orderDir: 'asc', limit: 10 }).then((response: any) => {
127
+ mediaAPI.validateSuccessResponse(response, 200)
128
+ expect(response.body.data).to.be.an('array')
129
+
130
+ if (response.body.data.length > 1) {
131
+ // Verify ascending order
132
+ const filenames = response.body.data.map((m: any) => m.filename)
133
+ const sorted = [...filenames].sort()
134
+ expect(filenames).to.deep.eq(sorted)
135
+ }
136
+
137
+ cy.log(`Sorted ${response.body.data.length} items by filename (asc)`)
138
+ })
139
+ })
140
+
141
+ it('MEDIA_API_007: Should sort media by uploadedAt (descending)', () => {
142
+ mediaAPI.getMedia({ orderBy: 'uploadedAt', orderDir: 'desc', limit: 10 }).then((response: any) => {
143
+ mediaAPI.validateSuccessResponse(response, 200)
144
+ expect(response.body.data).to.be.an('array')
145
+
146
+ if (response.body.data.length > 1) {
147
+ // Verify descending order by uploadedAt
148
+ const timestamps = response.body.data.map((m: any) => new Date(m.uploadedAt).getTime())
149
+ for (let i = 0; i < timestamps.length - 1; i++) {
150
+ expect(timestamps[i]).to.be.at.least(timestamps[i + 1])
151
+ }
152
+ }
153
+
154
+ cy.log(`Sorted ${response.body.data.length} items by uploadedAt (desc)`)
155
+ })
156
+ })
157
+
158
+ it('MEDIA_API_008: Should return empty array for non-matching search', () => {
159
+ const nonExistentTerm = 'NonExistentMediaFilename123456789'
160
+
161
+ mediaAPI.getMedia({ search: nonExistentTerm }).then((response: any) => {
162
+ mediaAPI.validateSuccessResponse(response, 200)
163
+ expect(response.body.data).to.be.an('array')
164
+ expect(response.body.data.length).to.eq(0)
165
+
166
+ cy.log('Search with non-matching term returns empty array')
167
+ })
168
+ })
169
+
170
+ it('MEDIA_API_009: Should reject request without API key', () => {
171
+ const noAuthAPI = new MediaAPIController(BASE_URL, null)
172
+
173
+ noAuthAPI.getMedia().then((response: any) => {
174
+ expect(response.status).to.eq(401)
175
+ expect(response.body).to.have.property('success', false)
176
+
177
+ cy.log('Request without API key rejected with 401')
178
+ })
179
+ })
180
+ })
181
+
182
+ // ============================================
183
+ // GET /api/v1/media/{id} - Get Media by ID
184
+ // ============================================
185
+ describe('GET /api/v1/media/{id} - Get Media by ID', () => {
186
+ let testMediaId: string
187
+
188
+ beforeEach(() => {
189
+ // Get first media item from list for testing
190
+ mediaAPI.getMedia({ limit: 1 }).then((response: any) => {
191
+ if (response.body.data.length > 0) {
192
+ testMediaId = response.body.data[0].id
193
+ }
194
+ })
195
+ })
196
+
197
+ it('MEDIA_API_010: Should get media by valid ID', () => {
198
+ cy.then(() => {
199
+ if (!testMediaId) {
200
+ cy.log('No media items available to test')
201
+ return
202
+ }
203
+
204
+ mediaAPI.getMediaById(testMediaId).then((response: any) => {
205
+ mediaAPI.validateSuccessResponse(response, 200)
206
+
207
+ const media = response.body.data
208
+ mediaAPI.validateMediaObject(media)
209
+ expect(media.id).to.eq(testMediaId)
210
+
211
+ cy.log(`Retrieved media: ${media.filename}`)
212
+ })
213
+ })
214
+ })
215
+
216
+ it('MEDIA_API_011: Should return 404 for non-existent media', () => {
217
+ const fakeId = 'non-existent-media-id-12345'
218
+
219
+ mediaAPI.getMediaById(fakeId).then((response: any) => {
220
+ expect(response.status).to.eq(404)
221
+ expect(response.body).to.have.property('success', false)
222
+
223
+ cy.log('Non-existent media returns 404')
224
+ })
225
+ })
226
+ })
227
+
228
+ // ============================================
229
+ // PATCH /api/v1/media/{id} - Update Media Metadata
230
+ // ============================================
231
+ describe('PATCH /api/v1/media/{id} - Update Media Metadata', () => {
232
+ let testMediaId: string
233
+
234
+ beforeEach(() => {
235
+ // Get first media item for testing
236
+ mediaAPI.getMedia({ limit: 1 }).then((response: any) => {
237
+ if (response.body.data.length > 0) {
238
+ testMediaId = response.body.data[0].id
239
+ }
240
+ })
241
+ })
242
+
243
+ it('MEDIA_API_020: Should update media alt text', () => {
244
+ cy.then(() => {
245
+ if (!testMediaId) {
246
+ cy.log('No media items available to test')
247
+ return
248
+ }
249
+
250
+ const updateData = {
251
+ alt: 'Updated alt text for testing'
252
+ }
253
+
254
+ mediaAPI.updateMedia(testMediaId, updateData).then((response: any) => {
255
+ mediaAPI.validateSuccessResponse(response, 200)
256
+
257
+ const media = response.body.data
258
+ expect(media.alt).to.eq(updateData.alt)
259
+
260
+ cy.log(`Updated media alt: ${media.alt}`)
261
+ })
262
+ })
263
+ })
264
+
265
+ it('MEDIA_API_021: Should update media caption', () => {
266
+ cy.then(() => {
267
+ if (!testMediaId) {
268
+ cy.log('No media items available to test')
269
+ return
270
+ }
271
+
272
+ const updateData = {
273
+ caption: 'Updated caption for testing'
274
+ }
275
+
276
+ mediaAPI.updateMedia(testMediaId, updateData).then((response: any) => {
277
+ mediaAPI.validateSuccessResponse(response, 200)
278
+
279
+ const media = response.body.data
280
+ expect(media.caption).to.eq(updateData.caption)
281
+
282
+ cy.log(`Updated media caption: ${media.caption}`)
283
+ })
284
+ })
285
+ })
286
+
287
+ it('MEDIA_API_022: Should update both alt and caption', () => {
288
+ cy.then(() => {
289
+ if (!testMediaId) {
290
+ cy.log('No media items available to test')
291
+ return
292
+ }
293
+
294
+ const updateData = {
295
+ alt: 'Updated alt text',
296
+ caption: 'Updated caption'
297
+ }
298
+
299
+ mediaAPI.updateMedia(testMediaId, updateData).then((response: any) => {
300
+ mediaAPI.validateSuccessResponse(response, 200)
301
+
302
+ const media = response.body.data
303
+ expect(media.alt).to.eq(updateData.alt)
304
+ expect(media.caption).to.eq(updateData.caption)
305
+
306
+ cy.log(`Updated both alt and caption`)
307
+ })
308
+ })
309
+ })
310
+
311
+ it('MEDIA_API_023: Should return 404 for non-existent media', () => {
312
+ const fakeId = 'non-existent-media-id-12345'
313
+
314
+ mediaAPI.updateMedia(fakeId, { alt: 'New Alt' }).then((response: any) => {
315
+ expect(response.status).to.eq(404)
316
+ expect(response.body).to.have.property('success', false)
317
+
318
+ cy.log('Update non-existent media returns 404')
319
+ })
320
+ })
321
+
322
+ it('MEDIA_API_024: Should reject empty update body', () => {
323
+ cy.then(() => {
324
+ if (!testMediaId) {
325
+ cy.log('No media items available to test')
326
+ return
327
+ }
328
+
329
+ mediaAPI.updateMedia(testMediaId, {}).then((response: any) => {
330
+ expect(response.status).to.eq(400)
331
+ expect(response.body).to.have.property('success', false)
332
+
333
+ cy.log('Empty update body rejected')
334
+ })
335
+ })
336
+ })
337
+
338
+ it('MEDIA_API_025: Should reject invalid update fields (only alt/caption allowed)', () => {
339
+ cy.then(() => {
340
+ if (!testMediaId) {
341
+ cy.log('No media items available to test')
342
+ return
343
+ }
344
+
345
+ // Try to update filename (should be rejected)
346
+ const invalidUpdate = {
347
+ filename: 'hacked-filename.jpg'
348
+ }
349
+
350
+ mediaAPI.updateMedia(testMediaId, invalidUpdate).then((response: any) => {
351
+ // Should return 400 (validation error) or ignore the invalid field
352
+ // Backend should only allow alt/caption updates
353
+ expect(response.status).to.be.oneOf([200, 400])
354
+
355
+ if (response.status === 200) {
356
+ // If 200, filename should NOT be changed
357
+ expect(response.body.data.filename).to.not.eq(invalidUpdate.filename)
358
+ }
359
+
360
+ cy.log('Invalid field update handled correctly')
361
+ })
362
+ })
363
+ })
364
+ })
365
+
366
+ // ============================================
367
+ // DELETE /api/v1/media/{id} - Soft Delete Media
368
+ // ============================================
369
+ describe('DELETE /api/v1/media/{id} - Soft Delete Media', () => {
370
+ let testMediaId: string
371
+
372
+ beforeEach(() => {
373
+ // Get a media item that can be deleted
374
+ mediaAPI.getMedia({ limit: 1, type: 'image' }).then((response: any) => {
375
+ if (response.body.data.length > 0) {
376
+ testMediaId = response.body.data[0].id
377
+ }
378
+ })
379
+ })
380
+
381
+ it('MEDIA_API_030: Should soft delete media by valid ID', () => {
382
+ cy.then(() => {
383
+ if (!testMediaId) {
384
+ cy.log('No media items available to test deletion')
385
+ return
386
+ }
387
+
388
+ // Delete the media
389
+ mediaAPI.deleteMedia(testMediaId).then((response: any) => {
390
+ mediaAPI.validateSuccessResponse(response, 200)
391
+ expect(response.body.data).to.have.property('success', true)
392
+ expect(response.body.data).to.have.property('id', testMediaId)
393
+
394
+ cy.log(`Soft deleted media: ${testMediaId}`)
395
+
396
+ // Verify soft delete: item should return 404 or status=deleted
397
+ mediaAPI.getMediaById(testMediaId).then((getResponse: any) => {
398
+ // After soft delete, GET should return 404 (filtered out)
399
+ expect(getResponse.status).to.eq(404)
400
+
401
+ cy.log('Verified soft deletion - media no longer accessible')
402
+ })
403
+ })
404
+ })
405
+ })
406
+
407
+ it('MEDIA_API_031: Should return 404 for non-existent media', () => {
408
+ const fakeId = 'non-existent-media-id-12345'
409
+
410
+ mediaAPI.deleteMedia(fakeId).then((response: any) => {
411
+ expect(response.status).to.eq(404)
412
+ expect(response.body).to.have.property('success', false)
413
+
414
+ cy.log('Delete non-existent media returns 404')
415
+ })
416
+ })
417
+
418
+ it('MEDIA_API_032: Should return 404 when deleting already deleted media', () => {
419
+ cy.then(() => {
420
+ if (!testMediaId) {
421
+ cy.log('No media items available to test')
422
+ return
423
+ }
424
+
425
+ // Delete once
426
+ mediaAPI.deleteMedia(testMediaId).then((deleteResponse: any) => {
427
+ expect(deleteResponse.status).to.eq(200)
428
+
429
+ // Try to delete again
430
+ mediaAPI.deleteMedia(testMediaId).then((secondDeleteResponse: any) => {
431
+ expect(secondDeleteResponse.status).to.eq(404)
432
+ expect(secondDeleteResponse.body).to.have.property('success', false)
433
+
434
+ cy.log('Second delete attempt correctly returns 404')
435
+ })
436
+ })
437
+ })
438
+ })
439
+ })
440
+
441
+ // ============================================
442
+ // Authentication Tests
443
+ // ============================================
444
+ describe('Authentication', () => {
445
+ it('MEDIA_API_040: Should accept session authentication', () => {
446
+ // Login as superadmin and use session cookies
447
+ cy.login('superadmin@tmt.dev', 'Test1234')
448
+
449
+ cy.request({
450
+ method: 'GET',
451
+ url: `${BASE_URL}/api/v1/media`,
452
+ failOnStatusCode: false
453
+ }).then((response) => {
454
+ expect(response.status).to.eq(200)
455
+ expect(response.body.success).to.be.true
456
+
457
+ cy.log('Session authentication successful')
458
+ })
459
+ })
460
+
461
+ it('MEDIA_API_041: Should accept API key authentication', () => {
462
+ cy.request({
463
+ method: 'GET',
464
+ url: `${BASE_URL}/api/v1/media`,
465
+ headers: {
466
+ 'Authorization': `Bearer ${SUPERADMIN_API_KEY}`
467
+ },
468
+ failOnStatusCode: false
469
+ }).then((response) => {
470
+ expect(response.status).to.eq(200)
471
+ expect(response.body.success).to.be.true
472
+
473
+ cy.log('API key authentication successful')
474
+ })
475
+ })
476
+
477
+ it('MEDIA_API_042: Should return 401 without authentication', () => {
478
+ cy.request({
479
+ method: 'GET',
480
+ url: `${BASE_URL}/api/v1/media`,
481
+ failOnStatusCode: false
482
+ }).then((response) => {
483
+ expect(response.status).to.eq(401)
484
+ expect(response.body.success).to.be.false
485
+
486
+ cy.log('No authentication returns 401')
487
+ })
488
+ })
489
+ })
490
+
491
+ // ============================================
492
+ // Edge Cases & Error Handling
493
+ // ============================================
494
+ describe('Edge Cases & Error Handling', () => {
495
+ it('MEDIA_API_050: Should handle invalid limit parameter', () => {
496
+ mediaAPI.getMedia({ limit: -1 }).then((response: any) => {
497
+ // Backend should either reject with 400 or use default limit
498
+ expect(response.status).to.be.oneOf([200, 400])
499
+
500
+ if (response.status === 200) {
501
+ // If accepted, should use default limit (not negative)
502
+ expect(response.body.info.limit).to.be.greaterThan(0)
503
+ }
504
+
505
+ cy.log('Invalid limit handled correctly')
506
+ })
507
+ })
508
+
509
+ it('MEDIA_API_051: Should handle invalid offset parameter', () => {
510
+ mediaAPI.getMedia({ offset: -1 }).then((response: any) => {
511
+ // Backend should either reject with 400 or use default offset (0)
512
+ expect(response.status).to.be.oneOf([200, 400])
513
+
514
+ if (response.status === 200) {
515
+ expect(response.body.info.offset).to.be.at.least(0)
516
+ }
517
+
518
+ cy.log('Invalid offset handled correctly')
519
+ })
520
+ })
521
+
522
+ it('MEDIA_API_052: Should handle invalid type filter', () => {
523
+ mediaAPI.getMedia({ type: 'invalid-type' }).then((response: any) => {
524
+ // Backend should either reject with 400 or return empty results
525
+ expect(response.status).to.be.oneOf([200, 400])
526
+
527
+ if (response.status === 200) {
528
+ // Should return empty array for invalid type
529
+ expect(response.body.data).to.be.an('array')
530
+ }
531
+
532
+ cy.log('Invalid type filter handled correctly')
533
+ })
534
+ })
535
+
536
+ it('MEDIA_API_053: Should handle invalid orderBy field', () => {
537
+ mediaAPI.getMedia({ orderBy: 'invalid-field' }).then((response: any) => {
538
+ // Backend should either reject with 400 or ignore invalid orderBy
539
+ expect(response.status).to.be.oneOf([200, 400])
540
+
541
+ cy.log('Invalid orderBy handled correctly')
542
+ })
543
+ })
544
+ })
545
+
546
+ // ============================================
547
+ // Integration - Read -> Update -> Delete Lifecycle
548
+ // ============================================
549
+ describe('Integration - Read -> Update -> Delete Lifecycle', () => {
550
+ it('MEDIA_API_100: Should complete lifecycle: Read -> Update -> Delete', () => {
551
+ // Get first available media item
552
+ mediaAPI.getMedia({ limit: 1 }).then((listResponse: any) => {
553
+ if (listResponse.body.data.length === 0) {
554
+ cy.log('No media items available for lifecycle test')
555
+ return
556
+ }
557
+
558
+ const mediaId = listResponse.body.data[0].id
559
+
560
+ // 1. READ
561
+ mediaAPI.getMediaById(mediaId).then((readResponse: any) => {
562
+ mediaAPI.validateSuccessResponse(readResponse, 200)
563
+ const originalFilename = readResponse.body.data.filename
564
+
565
+ cy.log(`1. Read media: ${originalFilename}`)
566
+
567
+ // 2. UPDATE
568
+ const updateData = {
569
+ alt: 'Lifecycle Test Alt',
570
+ caption: 'Lifecycle Test Caption'
571
+ }
572
+
573
+ mediaAPI.updateMedia(mediaId, updateData).then((updateResponse: any) => {
574
+ mediaAPI.validateSuccessResponse(updateResponse, 200)
575
+ expect(updateResponse.body.data.alt).to.eq(updateData.alt)
576
+ expect(updateResponse.body.data.caption).to.eq(updateData.caption)
577
+
578
+ cy.log(`2. Updated media metadata`)
579
+
580
+ // 3. DELETE
581
+ mediaAPI.deleteMedia(mediaId).then((deleteResponse: any) => {
582
+ mediaAPI.validateSuccessResponse(deleteResponse, 200)
583
+ expect(deleteResponse.body.data).to.have.property('success', true)
584
+
585
+ cy.log(`3. Deleted media: ${mediaId}`)
586
+
587
+ // 4. VERIFY DELETION
588
+ mediaAPI.getMediaById(mediaId).then((verifyResponse: any) => {
589
+ expect(verifyResponse.status).to.eq(404)
590
+
591
+ cy.log('4. Verified deletion - media no longer accessible')
592
+ cy.log('Full lifecycle completed successfully!')
593
+ })
594
+ })
595
+ })
596
+ })
597
+ })
598
+ })
599
+ })
600
+ })
@@ -0,0 +1,231 @@
1
+ /**
2
+ * MediaAPIController - Controller for interacting with the Media API
3
+ * Encapsulates all CRUD operations for /api/v1/media endpoints
4
+ *
5
+ * Requires:
6
+ * - API Key with media:read, media:write, media:delete scopes (or superadmin with *)
7
+ */
8
+ const BaseAPIController = require('./BaseAPIController')
9
+
10
+ class MediaAPIController extends BaseAPIController {
11
+ /**
12
+ * @param {string} baseUrl - Base URL for API requests
13
+ * @param {string|null} apiKey - API key for authentication
14
+ */
15
+ constructor(baseUrl = 'http://localhost:5173', apiKey = null) {
16
+ // Media does not require x-team-id (no team context)
17
+ super(baseUrl, apiKey, null, { slug: 'media' })
18
+ }
19
+
20
+ // ============================================================
21
+ // SEMANTIC ALIASES (for backward compatibility)
22
+ // ============================================================
23
+
24
+ /**
25
+ * GET /api/v1/media - Get list of media items
26
+ * @param {Object} options - Query options
27
+ * @param {number} [options.limit] - Results per page (default: 20)
28
+ * @param {number} [options.offset] - Offset for pagination (default: 0)
29
+ * @param {string} [options.type] - Filter by type (image, video, document, audio)
30
+ * @param {string} [options.search] - Search in filename
31
+ * @param {string} [options.orderBy] - Sort field (filename, uploadedAt, size, etc.)
32
+ * @param {string} [options.orderDir] - Sort direction (asc, desc)
33
+ * @param {Object} [options.headers] - Additional headers
34
+ * @returns {Cypress.Chainable} Cypress response
35
+ */
36
+ getMedia(options = {}) {
37
+ return this.list(options)
38
+ }
39
+
40
+ /**
41
+ * GET /api/v1/media/{id} - Get specific media item by ID
42
+ * @param {string} id - Media ID
43
+ * @param {Object} options - Additional options
44
+ * @returns {Cypress.Chainable} Cypress response
45
+ */
46
+ getMediaById(id, options = {}) {
47
+ return this.getById(id, options)
48
+ }
49
+
50
+ /**
51
+ * PATCH /api/v1/media/{id} - Update media metadata
52
+ * @param {string} id - Media ID
53
+ * @param {Object} updateData - Data to update
54
+ * @param {string} [updateData.alt] - Alt text for image
55
+ * @param {string} [updateData.caption] - Caption for media
56
+ * @param {Object} options - Additional options
57
+ * @returns {Cypress.Chainable} Cypress response
58
+ */
59
+ updateMedia(id, updateData, options = {}) {
60
+ return this.update(id, updateData, options)
61
+ }
62
+
63
+ /**
64
+ * DELETE /api/v1/media/{id} - Soft delete media item (status -> deleted)
65
+ * @param {string} id - Media ID
66
+ * @param {Object} options - Additional options
67
+ * @returns {Cypress.Chainable} Cypress response
68
+ */
69
+ deleteMedia(id, options = {}) {
70
+ return this.delete(id, options)
71
+ }
72
+
73
+ /**
74
+ * POST /api/v1/media/upload - Upload file(s)
75
+ * @param {File|File[]} files - File or array of files to upload
76
+ * @param {Object} options - Additional options
77
+ * @returns {Cypress.Chainable} Cypress response
78
+ */
79
+ uploadMedia(files, options = {}) {
80
+ const { headers = {} } = options
81
+ const formData = new FormData()
82
+
83
+ // Handle single file or array of files
84
+ const fileArray = Array.isArray(files) ? files : [files]
85
+ fileArray.forEach((file) => {
86
+ formData.append('files', file)
87
+ })
88
+
89
+ // Note: For FormData, Content-Type should be auto-set by browser/Cypress
90
+ const uploadHeaders = { ...this.getHeaders(headers) }
91
+ delete uploadHeaders['Content-Type'] // Let Cypress set multipart boundary
92
+
93
+ return cy.request({
94
+ method: 'POST',
95
+ url: `${this.baseUrl}/api/v1/media/upload`,
96
+ headers: uploadHeaders,
97
+ body: formData,
98
+ failOnStatusCode: false
99
+ })
100
+ }
101
+
102
+ // ============================================================
103
+ // DATA GENERATORS
104
+ // ============================================================
105
+
106
+ /**
107
+ * Generate random media data for testing
108
+ * @param {Object} overrides - Specific data to override
109
+ * @returns {Object} Generated media data
110
+ */
111
+ generateRandomMediaData(overrides = {}) {
112
+ const timestamp = Date.now()
113
+ const randomId = Math.random().toString(36).substring(2, 8)
114
+
115
+ return {
116
+ filename: `test-image-${randomId}-${timestamp}.jpg`,
117
+ alt: `Alt text for test image ${randomId}`,
118
+ caption: `Caption for test image ${randomId}`,
119
+ type: 'image',
120
+ size: Math.floor(100000 + Math.random() * 900000), // Random size
121
+ mimeType: 'image/jpeg',
122
+ ...overrides
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Create a test media item and return its data
128
+ * Note: This creates a DB record without actual file upload (for testing)
129
+ * @param {Object} mediaData - Media data (optional)
130
+ * @returns {Cypress.Chainable} Promise resolving with created media data
131
+ */
132
+ createTestMedia(mediaData = {}) {
133
+ const data = this.generateRandomMediaData(mediaData)
134
+ return this.create(data).then((response) => {
135
+ if (response.status === 201) {
136
+ return response.body.data
137
+ }
138
+ throw new Error(`Failed to create test media: ${response.status}`)
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Clean up a test media item (delete it)
144
+ * @param {string} id - Media ID
145
+ * @returns {Cypress.Chainable} Delete response
146
+ */
147
+ cleanupTestMedia(id) {
148
+ return this.deleteMedia(id)
149
+ }
150
+
151
+ // ============================================================
152
+ // ENTITY-SPECIFIC VALIDATORS
153
+ // ============================================================
154
+
155
+ /**
156
+ * Validate media object structure
157
+ * @param {Object} media - Media object
158
+ */
159
+ validateMediaObject(media) {
160
+ // Base fields
161
+ this.validateBaseEntityFields(media)
162
+
163
+ // Required media fields
164
+ expect(media).to.have.property('filename')
165
+ expect(media.filename).to.be.a('string')
166
+
167
+ expect(media).to.have.property('url')
168
+ expect(media.url).to.be.a('string')
169
+
170
+ expect(media).to.have.property('type')
171
+ expect(media.type).to.be.oneOf(['image', 'video', 'document', 'audio'])
172
+
173
+ expect(media).to.have.property('size')
174
+ expect(media.size).to.be.a('number')
175
+
176
+ expect(media).to.have.property('mimeType')
177
+ expect(media.mimeType).to.be.a('string')
178
+
179
+ expect(media).to.have.property('status')
180
+ expect(media.status).to.be.oneOf(['active', 'deleted'])
181
+
182
+ // Optional fields
183
+ if (media.alt !== null && media.alt !== undefined) {
184
+ expect(media.alt).to.be.a('string')
185
+ }
186
+
187
+ if (media.caption !== null && media.caption !== undefined) {
188
+ expect(media.caption).to.be.a('string')
189
+ }
190
+
191
+ if (media.thumbnailUrl !== null && media.thumbnailUrl !== undefined) {
192
+ expect(media.thumbnailUrl).to.be.a('string')
193
+ }
194
+
195
+ // Timestamps
196
+ expect(media).to.have.property('uploadedAt')
197
+ if (media.uploadedAt) {
198
+ expect(media.uploadedAt).to.be.a('string')
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Validate upload response structure
204
+ * @param {Object} response - Upload response body
205
+ */
206
+ validateUploadResponse(response) {
207
+ expect(response).to.have.property('success', true)
208
+ expect(response).to.have.property('urls')
209
+ expect(response.urls).to.be.an('array')
210
+
211
+ expect(response).to.have.property('media')
212
+ expect(response.media).to.be.an('array')
213
+
214
+ expect(response).to.have.property('count')
215
+ expect(response.count).to.be.a('number')
216
+ expect(response.count).to.eq(response.urls.length)
217
+ expect(response.count).to.eq(response.media.length)
218
+
219
+ expect(response).to.have.property('storage')
220
+ expect(response.storage).to.have.property('provider')
221
+ expect(response.storage).to.have.property('bucket')
222
+ }
223
+ }
224
+
225
+ // Export class for use in tests
226
+ module.exports = MediaAPIController
227
+
228
+ // For global use in Cypress
229
+ if (typeof window !== 'undefined') {
230
+ window.MediaAPIController = MediaAPIController
231
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 NextSpark
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.