1
- import { BadRequestException , NotFoundException , UnauthorizedException } from '@nestjs/common' ;
1
+ import {
2
+ BadRequestException ,
3
+ InternalServerErrorException ,
4
+ NotFoundException ,
5
+ UnauthorizedException ,
6
+ } from '@nestjs/common' ;
2
7
import { Stats } from 'node:fs' ;
3
8
import { AssetMediaStatus , AssetRejectReason , AssetUploadAction } from 'src/dtos/asset-media-response.dto' ;
4
- import { AssetMediaCreateDto , AssetMediaReplaceDto , UploadFieldName } from 'src/dtos/asset-media.dto' ;
9
+ import { AssetMediaCreateDto , AssetMediaReplaceDto , AssetMediaSize , UploadFieldName } from 'src/dtos/asset-media.dto' ;
5
10
import { AssetFileEntity } from 'src/entities/asset-files.entity' ;
6
11
import { ASSET_CHECKSUM_CONSTRAINT , AssetEntity } from 'src/entities/asset.entity' ;
7
- import { AssetStatus , AssetType , CacheControl } from 'src/enum' ;
12
+ import { AssetFileType , AssetStatus , AssetType , CacheControl } from 'src/enum' ;
8
13
import { IAssetRepository } from 'src/interfaces/asset.interface' ;
9
14
import { IJobRepository , JobName } from 'src/interfaces/job.interface' ;
10
15
import { IStorageRepository } from 'src/interfaces/storage.interface' ;
@@ -14,6 +19,7 @@ import { ImmichFileResponse } from 'src/utils/file';
14
19
import { assetStub } from 'test/fixtures/asset.stub' ;
15
20
import { authStub } from 'test/fixtures/auth.stub' ;
16
21
import { fileStub } from 'test/fixtures/file.stub' ;
22
+ import { userStub } from 'test/fixtures/user.stub' ;
17
23
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock' ;
18
24
import { newTestService } from 'test/utils' ;
19
25
import { QueryFailedError } from 'typeorm' ;
@@ -194,6 +200,10 @@ describe(AssetMediaService.name, () => {
194
200
} ) ;
195
201
196
202
describe ( 'getUploadAssetIdByChecksum' , ( ) => {
203
+ it ( 'should return if checksum is undefined' , async ( ) => {
204
+ await expect ( sut . getUploadAssetIdByChecksum ( authStub . admin ) ) . resolves . toBe ( undefined ) ;
205
+ } ) ;
206
+
197
207
it ( 'should handle a non-existent asset' , async ( ) => {
198
208
await expect ( sut . getUploadAssetIdByChecksum ( authStub . admin , file1 . toString ( 'hex' ) ) ) . resolves . toBeUndefined ( ) ;
199
209
expect ( assetMock . getUploadAssetIdByChecksum ) . toHaveBeenCalledWith ( authStub . admin . user . id , file1 ) ;
@@ -295,6 +305,35 @@ describe(AssetMediaService.name, () => {
295
305
} ) ;
296
306
297
307
describe ( 'uploadAsset' , ( ) => {
308
+ it ( 'should throw an error if the quota is exceeded' , async ( ) => {
309
+ const file = {
310
+ uuid : 'random-uuid' ,
311
+ originalPath : 'fake_path/asset_1.jpeg' ,
312
+ mimeType : 'image/jpeg' ,
313
+ checksum : Buffer . from ( 'file hash' , 'utf8' ) ,
314
+ originalName : 'asset_1.jpeg' ,
315
+ size : 42 ,
316
+ } ;
317
+
318
+ assetMock . create . mockResolvedValue ( assetEntity ) ;
319
+
320
+ await expect (
321
+ sut . uploadAsset (
322
+ { ...authStub . admin , user : { ...authStub . admin . user , quotaSizeInBytes : 42 , quotaUsageInBytes : 1 } } ,
323
+ createDto ,
324
+ file ,
325
+ ) ,
326
+ ) . rejects . toBeInstanceOf ( BadRequestException ) ;
327
+
328
+ expect ( assetMock . create ) . not . toHaveBeenCalled ( ) ;
329
+ expect ( userMock . updateUsage ) . not . toHaveBeenCalledWith ( authStub . user1 . user . id , file . size ) ;
330
+ expect ( storageMock . utimes ) . not . toHaveBeenCalledWith (
331
+ file . originalPath ,
332
+ expect . any ( Date ) ,
333
+ new Date ( createDto . fileModifiedAt ) ,
334
+ ) ;
335
+ } ) ;
336
+
298
337
it ( 'should handle a file upload' , async ( ) => {
299
338
const file = {
300
339
uuid : 'random-uuid' ,
@@ -348,6 +387,31 @@ describe(AssetMediaService.name, () => {
348
387
expect ( userMock . updateUsage ) . not . toHaveBeenCalled ( ) ;
349
388
} ) ;
350
389
390
+ it ( 'should throw an error if the duplicate could not be found by checksum' , async ( ) => {
391
+ const file = {
392
+ uuid : 'random-uuid' ,
393
+ originalPath : 'fake_path/asset_1.jpeg' ,
394
+ mimeType : 'image/jpeg' ,
395
+ checksum : Buffer . from ( 'file hash' , 'utf8' ) ,
396
+ originalName : 'asset_1.jpeg' ,
397
+ size : 0 ,
398
+ } ;
399
+ const error = new QueryFailedError ( '' , [ ] , new Error ( 'unique key violation' ) ) ;
400
+ ( error as any ) . constraint = ASSET_CHECKSUM_CONSTRAINT ;
401
+
402
+ assetMock . create . mockRejectedValue ( error ) ;
403
+
404
+ await expect ( sut . uploadAsset ( authStub . user1 , createDto , file ) ) . rejects . toBeInstanceOf (
405
+ InternalServerErrorException ,
406
+ ) ;
407
+
408
+ expect ( jobMock . queue ) . toHaveBeenCalledWith ( {
409
+ name : JobName . DELETE_FILES ,
410
+ data : { files : [ 'fake_path/asset_1.jpeg' , undefined ] } ,
411
+ } ) ;
412
+ expect ( userMock . updateUsage ) . not . toHaveBeenCalled ( ) ;
413
+ } ) ;
414
+
351
415
it ( 'should handle a live photo' , async ( ) => {
352
416
assetMock . getById . mockResolvedValueOnce ( assetStub . livePhotoMotionAsset ) ;
353
417
assetMock . create . mockResolvedValueOnce ( assetStub . livePhotoStillAsset ) ;
@@ -385,6 +449,23 @@ describe(AssetMediaService.name, () => {
385
449
expect ( assetMock . getById ) . toHaveBeenCalledWith ( 'live-photo-motion-asset' ) ;
386
450
expect ( assetMock . update ) . toHaveBeenCalledWith ( { id : 'live-photo-motion-asset' , isVisible : false } ) ;
387
451
} ) ;
452
+
453
+ it ( 'should handle a sidecar file' , async ( ) => {
454
+ assetMock . getById . mockResolvedValueOnce ( assetStub . image ) ;
455
+ assetMock . create . mockResolvedValueOnce ( assetStub . image ) ;
456
+
457
+ await expect ( sut . uploadAsset ( authStub . user1 , createDto , fileStub . photo , fileStub . photoSidecar ) ) . resolves . toEqual ( {
458
+ status : AssetMediaStatus . CREATED ,
459
+ id : assetStub . image . id ,
460
+ } ) ;
461
+
462
+ expect ( storageMock . utimes ) . toHaveBeenCalledWith (
463
+ fileStub . photoSidecar . originalPath ,
464
+ expect . any ( Date ) ,
465
+ new Date ( createDto . fileModifiedAt ) ,
466
+ ) ;
467
+ expect ( assetMock . update ) . not . toHaveBeenCalled ( ) ;
468
+ } ) ;
388
469
} ) ;
389
470
390
471
describe ( 'downloadOriginal' , ( ) => {
@@ -419,6 +500,170 @@ describe(AssetMediaService.name, () => {
419
500
} ) ;
420
501
} ) ;
421
502
503
+ describe ( 'viewThumbnail' , ( ) => {
504
+ it ( 'should require asset.view permissions' , async ( ) => {
505
+ await expect ( sut . viewThumbnail ( authStub . admin , 'id' , { } ) ) . rejects . toBeInstanceOf ( BadRequestException ) ;
506
+
507
+ expect ( accessMock . asset . checkOwnerAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
508
+ expect ( accessMock . asset . checkAlbumAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
509
+ expect ( accessMock . asset . checkPartnerAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
510
+ } ) ;
511
+
512
+ it ( 'should throw an error if the asset does not exist' , async ( ) => {
513
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
514
+ assetMock . getById . mockResolvedValue ( null ) ;
515
+
516
+ await expect (
517
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . PREVIEW } ) ,
518
+ ) . rejects . toBeInstanceOf ( NotFoundException ) ;
519
+ } ) ;
520
+
521
+ it ( 'should throw an error if the requested thumbnail file does not exist' , async ( ) => {
522
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
523
+ assetMock . getById . mockResolvedValue ( { ...assetStub . image , files : [ ] } ) ;
524
+
525
+ await expect (
526
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . THUMBNAIL } ) ,
527
+ ) . rejects . toBeInstanceOf ( NotFoundException ) ;
528
+ } ) ;
529
+
530
+ it ( 'should throw an error if the requested preview file does not exist' , async ( ) => {
531
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
532
+ assetMock . getById . mockResolvedValue ( {
533
+ ...assetStub . image ,
534
+ files : [
535
+ {
536
+ assetId : assetStub . image . id ,
537
+ createdAt : assetStub . image . fileCreatedAt ,
538
+ id : '42' ,
539
+ path : '/path/to/preview' ,
540
+ type : AssetFileType . THUMBNAIL ,
541
+ updatedAt : new Date ( ) ,
542
+ } ,
543
+ ] ,
544
+ } ) ;
545
+ await expect (
546
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . PREVIEW } ) ,
547
+ ) . rejects . toBeInstanceOf ( NotFoundException ) ;
548
+ } ) ;
549
+
550
+ it ( 'should fall back to preview if the requested thumbnail file does not exist' , async ( ) => {
551
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
552
+ assetMock . getById . mockResolvedValue ( {
553
+ ...assetStub . image ,
554
+ files : [
555
+ {
556
+ assetId : assetStub . image . id ,
557
+ createdAt : assetStub . image . fileCreatedAt ,
558
+ id : '42' ,
559
+ path : '/path/to/preview.jpg' ,
560
+ type : AssetFileType . PREVIEW ,
561
+ updatedAt : new Date ( ) ,
562
+ } ,
563
+ ] ,
564
+ } ) ;
565
+
566
+ await expect (
567
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . THUMBNAIL } ) ,
568
+ ) . resolves . toEqual (
569
+ new ImmichFileResponse ( {
570
+ path : '/path/to/preview.jpg' ,
571
+ cacheControl : CacheControl . PRIVATE_WITH_CACHE ,
572
+ contentType : 'image/jpeg' ,
573
+ } ) ,
574
+ ) ;
575
+ } ) ;
576
+
577
+ it ( 'should get preview file' , async ( ) => {
578
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
579
+ assetMock . getById . mockResolvedValue ( { ...assetStub . image } ) ;
580
+ await expect (
581
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . PREVIEW } ) ,
582
+ ) . resolves . toEqual (
583
+ new ImmichFileResponse ( {
584
+ path : assetStub . image . files [ 0 ] . path ,
585
+ cacheControl : CacheControl . PRIVATE_WITH_CACHE ,
586
+ contentType : 'image/jpeg' ,
587
+ } ) ,
588
+ ) ;
589
+ } ) ;
590
+
591
+ it ( 'should get thumbnail file' , async ( ) => {
592
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
593
+ assetMock . getById . mockResolvedValue ( { ...assetStub . image } ) ;
594
+ await expect (
595
+ sut . viewThumbnail ( authStub . admin , assetStub . image . id , { size : AssetMediaSize . THUMBNAIL } ) ,
596
+ ) . resolves . toEqual (
597
+ new ImmichFileResponse ( {
598
+ path : assetStub . image . files [ 1 ] . path ,
599
+ cacheControl : CacheControl . PRIVATE_WITH_CACHE ,
600
+ contentType : 'application/octet-stream' ,
601
+ } ) ,
602
+ ) ;
603
+ } ) ;
604
+ } ) ;
605
+
606
+ describe ( 'playbackVideo' , ( ) => {
607
+ it ( 'should require asset.view permissions' , async ( ) => {
608
+ await expect ( sut . playbackVideo ( authStub . admin , 'id' ) ) . rejects . toBeInstanceOf ( BadRequestException ) ;
609
+
610
+ expect ( accessMock . asset . checkOwnerAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
611
+ expect ( accessMock . asset . checkAlbumAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
612
+ expect ( accessMock . asset . checkPartnerAccess ) . toHaveBeenCalledWith ( userStub . admin . id , new Set ( [ 'id' ] ) ) ;
613
+ } ) ;
614
+
615
+ it ( 'should throw an error if the asset does not exist' , async ( ) => {
616
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
617
+ assetMock . getById . mockResolvedValue ( null ) ;
618
+
619
+ await expect ( sut . playbackVideo ( authStub . admin , assetStub . image . id ) ) . rejects . toBeInstanceOf ( NotFoundException ) ;
620
+ } ) ;
621
+
622
+ it ( 'should throw an error if the asset is not a video' , async ( ) => {
623
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . image . id ] ) ) ;
624
+ assetMock . getById . mockResolvedValue ( assetStub . image ) ;
625
+
626
+ await expect ( sut . playbackVideo ( authStub . admin , assetStub . image . id ) ) . rejects . toBeInstanceOf ( BadRequestException ) ;
627
+ } ) ;
628
+
629
+ it ( 'should return the encoded video path if available' , async ( ) => {
630
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . hasEncodedVideo . id ] ) ) ;
631
+ assetMock . getById . mockResolvedValue ( assetStub . hasEncodedVideo ) ;
632
+
633
+ await expect ( sut . playbackVideo ( authStub . admin , assetStub . hasEncodedVideo . id ) ) . resolves . toEqual (
634
+ new ImmichFileResponse ( {
635
+ path : assetStub . hasEncodedVideo . encodedVideoPath ! ,
636
+ cacheControl : CacheControl . PRIVATE_WITH_CACHE ,
637
+ contentType : 'video/mp4' ,
638
+ } ) ,
639
+ ) ;
640
+ } ) ;
641
+
642
+ it ( 'should fall back to the original path' , async ( ) => {
643
+ accessMock . asset . checkOwnerAccess . mockResolvedValue ( new Set ( [ assetStub . video . id ] ) ) ;
644
+ assetMock . getById . mockResolvedValue ( assetStub . video ) ;
645
+
646
+ await expect ( sut . playbackVideo ( authStub . admin , assetStub . video . id ) ) . resolves . toEqual (
647
+ new ImmichFileResponse ( {
648
+ path : assetStub . video . originalPath ,
649
+ cacheControl : CacheControl . PRIVATE_WITH_CACHE ,
650
+ contentType : 'application/octet-stream' ,
651
+ } ) ,
652
+ ) ;
653
+ } ) ;
654
+ } ) ;
655
+
656
+ describe ( 'checkExistingAssets' , ( ) => {
657
+ it ( 'should get existing asset ids' , async ( ) => {
658
+ assetMock . getByDeviceIds . mockResolvedValue ( [ '42' ] ) ;
659
+ await expect (
660
+ sut . checkExistingAssets ( authStub . admin , { deviceId : '420' , deviceAssetIds : [ '69' ] } ) ,
661
+ ) . resolves . toEqual ( { existingIds : [ '42' ] } ) ;
662
+
663
+ expect ( assetMock . getByDeviceIds ) . toHaveBeenCalledWith ( userStub . admin . id , '420' , [ '69' ] ) ;
664
+ } ) ;
665
+ } ) ;
666
+
422
667
describe ( 'replaceAsset' , ( ) => {
423
668
it ( 'should error when update photo does not exist' , async ( ) => {
424
669
assetMock . getById . mockResolvedValueOnce ( null ) ;
@@ -601,5 +846,37 @@ describe(AssetMediaService.name, () => {
601
846
602
847
expect ( assetMock . getByChecksums ) . toHaveBeenCalledWith ( authStub . admin . user . id , [ file1 , file2 ] ) ;
603
848
} ) ;
849
+
850
+ it ( 'should return non-duplicates as well' , async ( ) => {
851
+ const file1 = Buffer . from ( 'd2947b871a706081be194569951b7db246907957' , 'hex' ) ;
852
+ const file2 = Buffer . from ( '53be335e99f18a66ff12e9a901c7a6171dd76573' , 'hex' ) ;
853
+
854
+ assetMock . getByChecksums . mockResolvedValue ( [ { id : 'asset-1' , checksum : file1 } as AssetEntity ] ) ;
855
+
856
+ await expect (
857
+ sut . bulkUploadCheck ( authStub . admin , {
858
+ assets : [
859
+ { id : '1' , checksum : file1 . toString ( 'hex' ) } ,
860
+ { id : '2' , checksum : file2 . toString ( 'base64' ) } ,
861
+ ] ,
862
+ } ) ,
863
+ ) . resolves . toEqual ( {
864
+ results : [
865
+ {
866
+ id : '1' ,
867
+ assetId : 'asset-1' ,
868
+ action : AssetUploadAction . REJECT ,
869
+ reason : AssetRejectReason . DUPLICATE ,
870
+ isTrashed : false ,
871
+ } ,
872
+ {
873
+ id : '2' ,
874
+ action : AssetUploadAction . ACCEPT ,
875
+ } ,
876
+ ] ,
877
+ } ) ;
878
+
879
+ expect ( assetMock . getByChecksums ) . toHaveBeenCalledWith ( authStub . admin . user . id , [ file1 , file2 ] ) ;
880
+ } ) ;
604
881
} ) ;
605
882
} ) ;
0 commit comments