Selaa lähdekoodia

refactor(server): test fixtures (#3491)

Jason Rasmussen 2 vuotta sitten
vanhempi
commit
9e085c1071
32 muutettua tiedostoa jossa 1545 lisäystä ja 1538 poistoa
  1. 4 4
      server/src/domain/album/album.service.spec.ts
  2. 24 24
      server/src/domain/asset/asset.service.spec.ts
  3. 31 31
      server/src/domain/auth/auth.service.spec.ts
  4. 19 19
      server/src/domain/facial-recognition/facial-recognition.service.spec.ts
  5. 2 2
      server/src/domain/job/job.service.spec.ts
  6. 78 78
      server/src/domain/media/media.service.spec.ts
  7. 19 19
      server/src/domain/metadata/metadata.service.spec.ts
  8. 9 9
      server/src/domain/person/person.service.spec.ts
  9. 7 7
      server/src/domain/search/search.service.spec.ts
  10. 9 9
      server/src/domain/shared-link/shared-link.service.spec.ts
  11. 9 13
      server/src/domain/smart-info/smart-info.service.spec.ts
  12. 26 26
      server/src/domain/storage-template/storage-template.service.spec.ts
  13. 2 2
      server/src/domain/tag/tag.service.spec.ts
  14. 3 3
      server/src/immich/api-v1/album/album.service.spec.ts
  15. 28 30
      server/src/immich/api-v1/asset/asset.service.spec.ts
  16. 0 1262
      server/test/fixtures.ts
  17. 124 0
      server/test/fixtures/album.stub.ts
  18. 13 0
      server/test/fixtures/api-key.stub.ts
  19. 291 0
      server/test/fixtures/asset.stub.ts
  20. 127 0
      server/test/fixtures/auth.stub.ts
  21. 58 0
      server/test/fixtures/face.stub.ts
  22. 12 0
      server/test/fixtures/file.stub.ts
  23. 15 0
      server/test/fixtures/index.ts
  24. 95 0
      server/test/fixtures/media.stub.ts
  25. 21 0
      server/test/fixtures/partner.stub.ts
  26. 82 0
      server/test/fixtures/person.stub.ts
  27. 12 0
      server/test/fixtures/search.stub.ts
  28. 282 0
      server/test/fixtures/shared-link.stub.ts
  29. 25 0
      server/test/fixtures/system-config.stub.ts
  30. 24 0
      server/test/fixtures/tag.stub.ts
  31. 25 0
      server/test/fixtures/user-token.stub.ts
  32. 69 0
      server/test/fixtures/user.stub.ts

+ 4 - 4
server/src/domain/album/album.service.spec.ts

@@ -8,7 +8,7 @@ import {
   newAssetRepositoryMock,
   newJobRepositoryMock,
   newUserRepositoryMock,
-  userEntityStub,
+  userStub,
 } from '@test';
 import _ from 'lodash';
 import { IAssetRepository } from '../asset';
@@ -326,12 +326,12 @@ describe(AlbumService.name, () => {
       accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
       albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
-      userMock.get.mockResolvedValue(userEntityStub.user2);
+      userMock.get.mockResolvedValue(userStub.user2);
       await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
       expect(albumMock.update).toHaveBeenCalledWith({
         id: albumStub.sharedWithAdmin.id,
         updatedAt: expect.any(Date),
-        sharedUsers: [userEntityStub.admin, { id: authStub.user2.id }],
+        sharedUsers: [userStub.admin, { id: authStub.user2.id }],
       });
     });
   });
@@ -349,7 +349,7 @@ describe(AlbumService.name, () => {
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
 
       await expect(
-        sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userEntityStub.user1.id),
+        sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
       ).resolves.toBeUndefined();
 
       expect(albumMock.update).toHaveBeenCalledTimes(1);

+ 24 - 24
server/src/domain/asset/asset.service.spec.ts

@@ -1,7 +1,7 @@
 import { AssetType } from '@app/infra/entities';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import {
-  assetEntityStub,
+  assetStub,
   authStub,
   IAccessRepositoryMock,
   newAccessRepositoryMock,
@@ -246,7 +246,7 @@ describe(AssetService.name, () => {
   describe('getMapMarkers', () => {
     it('should get geo information of assets', async () => {
       assetMock.getMapMarkers.mockResolvedValue(
-        [assetEntityStub.withLocation].map((asset) => ({
+        [assetStub.withLocation].map((asset) => ({
           id: asset.id,
 
           /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
@@ -261,7 +261,7 @@ describe(AssetService.name, () => {
 
       expect(markers).toHaveLength(1);
       expect(markers[0]).toEqual({
-        id: assetEntityStub.withLocation.id,
+        id: assetStub.withLocation.id,
         lat: 100,
         lon: 100,
       });
@@ -308,14 +308,14 @@ describe(AssetService.name, () => {
     it('should set the title correctly', async () => {
       when(assetMock.getByDate)
         .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z'))
-        .mockResolvedValue([assetEntityStub.image]);
+        .mockResolvedValue([assetStub.image]);
       when(assetMock.getByDate)
         .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z'))
-        .mockResolvedValue([assetEntityStub.video]);
+        .mockResolvedValue([assetStub.video]);
 
       await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([
-        { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] },
-        { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] },
+        { title: '1 year since...', assets: [mapAsset(assetStub.image)] },
+        { title: '2 years since...', assets: [mapAsset(assetStub.video)] },
       ]);
 
       expect(assetMock.getByDate).toHaveBeenCalledTimes(2);
@@ -352,12 +352,12 @@ describe(AssetService.name, () => {
       const stream = new Readable();
 
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
       storageMock.createReadStream.mockResolvedValue({ stream });
 
       await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream });
 
-      expect(storageMock.createReadStream).toHaveBeenCalledWith(assetEntityStub.image.originalPath, 'image/jpeg');
+      expect(storageMock.createReadStream).toHaveBeenCalledWith(assetStub.image.originalPath, 'image/jpeg');
     });
 
     it('should download an archive', async () => {
@@ -368,7 +368,7 @@ describe(AssetService.name, () => {
       };
 
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noWebpPath]);
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noWebpPath]);
       storageMock.createZipStream.mockReturnValue(archiveMock);
 
       await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
@@ -388,7 +388,7 @@ describe(AssetService.name, () => {
       };
 
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noResizePath]);
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noResizePath]);
       storageMock.createZipStream.mockReturnValue(archiveMock);
 
       await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
@@ -408,7 +408,7 @@ describe(AssetService.name, () => {
 
     it('should return a list of archives (assetIds)', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]);
 
       const assetIds = ['asset-1', 'asset-2'];
       await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse);
@@ -419,7 +419,7 @@ describe(AssetService.name, () => {
     it('should return a list of archives (albumId)', async () => {
       accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       assetMock.getByAlbumId.mockResolvedValue({
-        items: [assetEntityStub.image, assetEntityStub.video],
+        items: [assetStub.image, assetStub.video],
         hasNextPage: false,
       });
 
@@ -431,7 +431,7 @@ describe(AssetService.name, () => {
 
     it('should return a list of archives (userId)', async () => {
       assetMock.getByUserId.mockResolvedValue({
-        items: [assetEntityStub.image, assetEntityStub.video],
+        items: [assetStub.image, assetStub.video],
         hasNextPage: false,
       });
 
@@ -445,10 +445,10 @@ describe(AssetService.name, () => {
     it('should split archives by size', async () => {
       assetMock.getByUserId.mockResolvedValue({
         items: [
-          { ...assetEntityStub.image, id: 'asset-1' },
-          { ...assetEntityStub.video, id: 'asset-2' },
-          { ...assetEntityStub.withLocation, id: 'asset-3' },
-          { ...assetEntityStub.noWebpPath, id: 'asset-4' },
+          { ...assetStub.image, id: 'asset-1' },
+          { ...assetStub.video, id: 'asset-2' },
+          { ...assetStub.withLocation, id: 'asset-3' },
+          { ...assetStub.noWebpPath, id: 'asset-4' },
         ],
         hasNextPage: false,
       });
@@ -470,18 +470,18 @@ describe(AssetService.name, () => {
     it('should include the video portion of a live photo', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
       when(assetMock.getByIds)
-        .calledWith([assetEntityStub.livePhotoStillAsset.id])
-        .mockResolvedValue([assetEntityStub.livePhotoStillAsset]);
+        .calledWith([assetStub.livePhotoStillAsset.id])
+        .mockResolvedValue([assetStub.livePhotoStillAsset]);
       when(assetMock.getByIds)
-        .calledWith([assetEntityStub.livePhotoMotionAsset.id])
-        .mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
+        .calledWith([assetStub.livePhotoMotionAsset.id])
+        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
 
-      const assetIds = [assetEntityStub.livePhotoStillAsset.id];
+      const assetIds = [assetStub.livePhotoStillAsset.id];
       await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
         totalSize: 125_000,
         archives: [
           {
-            assetIds: [assetEntityStub.livePhotoStillAsset.id, assetEntityStub.livePhotoMotionAsset.id],
+            assetIds: [assetStub.livePhotoStillAsset.id, assetStub.livePhotoMotionAsset.id],
             size: 125_000,
           },
         ],

+ 31 - 31
server/src/domain/auth/auth.service.spec.ts

@@ -12,8 +12,8 @@ import {
   newUserTokenRepositoryMock,
   sharedLinkStub,
   systemConfigStub,
-  userEntityStub,
-  userTokenEntityStub,
+  userStub,
+  userTokenStub,
 } from '@test';
 import { IncomingHttpHeaders } from 'http';
 import { generators, Issuer } from 'openid-client';
@@ -112,15 +112,15 @@ describe('AuthService', () => {
     });
 
     it('should successfully log the user in', async () => {
-      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getByEmail.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
       await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
       expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
     });
 
     it('should generate the cookie headers (insecure)', async () => {
-      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getByEmail.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
       await expect(
         sut.login(fixtures.login, {
           clientIp: '127.0.0.1',
@@ -246,10 +246,10 @@ describe('AuthService', () => {
     });
 
     it('should validate using authorization header', async () => {
-      userMock.get.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.get.mockResolvedValue(userStub.user1);
+      userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
       const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
-      await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
+      await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userStub.user1);
     });
   });
 
@@ -275,7 +275,7 @@ describe('AuthService', () => {
 
     it('should accept a base64url key', async () => {
       shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
-      userMock.get.mockResolvedValue(userEntityStub.admin);
+      userMock.get.mockResolvedValue(userStub.admin);
       const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') };
       await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
       expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
@@ -283,7 +283,7 @@ describe('AuthService', () => {
 
     it('should accept a hex key', async () => {
       shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
-      userMock.get.mockResolvedValue(userEntityStub.admin);
+      userMock.get.mockResolvedValue(userStub.admin);
       const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') };
       await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
       expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
@@ -298,16 +298,16 @@ describe('AuthService', () => {
     });
 
     it('should return an auth dto', async () => {
-      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
+      userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
       const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
-      await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
+      await expect(sut.validate(headers, {})).resolves.toEqual(userStub.user1);
     });
 
     it('should update when access time exceeds an hour', async () => {
-      userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
-      userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
+      userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken);
+      userTokenMock.save.mockResolvedValue(userTokenStub.userToken);
       const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
-      await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
+      await expect(sut.validate(headers, {})).resolves.toEqual(userStub.user1);
       expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
         id: 'not_active',
         token: 'auth_token',
@@ -338,7 +338,7 @@ describe('AuthService', () => {
 
   describe('getDevices', () => {
     it('should get the devices', async () => {
-      userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
+      userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]);
       await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
         {
           createdAt: '2021-01-01T00:00:00.000Z',
@@ -364,7 +364,7 @@ describe('AuthService', () => {
 
   describe('logoutDevices', () => {
     it('should logout all devices', async () => {
-      userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.inactiveToken, userTokenEntityStub.userToken]);
+      userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]);
 
       await sut.logoutDevices(authStub.user1);
 
@@ -429,24 +429,24 @@ describe('AuthService', () => {
 
     it('should link an existing user', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
-      userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
-      userMock.update.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getByEmail.mockResolvedValue(userStub.user1);
+      userMock.update.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
 
       await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
         loginResponseStub.user1oauth,
       );
 
       expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
-      expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
+      expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub });
     });
 
     it('should allow auto registering by default', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.enabled);
       userMock.getByEmail.mockResolvedValue(null);
-      userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
-      userMock.create.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getAdmin.mockResolvedValue(userStub.user1);
+      userMock.create.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
 
       await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
         loginResponseStub.user1oauth,
@@ -458,8 +458,8 @@ describe('AuthService', () => {
 
     it('should use the mobile redirect override', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.override);
-      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getByOAuthId.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
 
       await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
 
@@ -468,8 +468,8 @@ describe('AuthService', () => {
 
     it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.override);
-      userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
-      userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
+      userMock.getByOAuthId.mockResolvedValue(userStub.user1);
+      userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
 
       await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
 
@@ -480,7 +480,7 @@ describe('AuthService', () => {
   describe('link', () => {
     it('should link an account', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.enabled);
-      userMock.update.mockResolvedValue(userEntityStub.user1);
+      userMock.update.mockResolvedValue(userStub.user1);
 
       await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
 
@@ -502,7 +502,7 @@ describe('AuthService', () => {
   describe('unlink', () => {
     it('should unlink an account', async () => {
       configMock.load.mockResolvedValue(systemConfigStub.enabled);
-      userMock.update.mockResolvedValue(userEntityStub.user1);
+      userMock.update.mockResolvedValue(userStub.user1);
 
       await sut.unlink(authStub.user1);
 

+ 19 - 19
server/src/domain/facial-recognition/facial-recognition.service.spec.ts

@@ -1,5 +1,5 @@
 import {
-  assetEntityStub,
+  assetStub,
   faceStub,
   newAssetRepositoryMock,
   newFaceRepositoryMock,
@@ -133,7 +133,7 @@ describe(FacialRecognitionService.name, () => {
   describe('handleQueueRecognizeFaces', () => {
     it('should queue missing assets', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
       await sut.handleQueueRecognizeFaces({});
@@ -141,13 +141,13 @@ describe(FacialRecognitionService.name, () => {
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.RECOGNIZE_FACES,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
 
     it('should queue all assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
       personMock.deleteAll.mockResolvedValue(5);
@@ -158,24 +158,24 @@ describe(FacialRecognitionService.name, () => {
       expect(assetMock.getAll).toHaveBeenCalled();
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.RECOGNIZE_FACES,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
   });
 
   describe('handleRecognizeFaces', () => {
     it('should skip when no resize path', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
-      await sut.handleRecognizeFaces({ id: assetEntityStub.noResizePath.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
+      await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id });
       expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
     });
 
     it('should handle no results', async () => {
       machineLearningMock.detectFaces.mockResolvedValue([]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleRecognizeFaces({ id: assetStub.image.id });
       expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
-        imagePath: assetEntityStub.image.resizePath,
+        imagePath: assetStub.image.resizePath,
       });
       expect(faceMock.create).not.toHaveBeenCalled();
       expect(jobMock.queue).not.toHaveBeenCalled();
@@ -184,8 +184,8 @@ describe(FacialRecognitionService.name, () => {
     it('should match existing people', async () => {
       machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
       searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleRecognizeFaces({ id: assetStub.image.id });
 
       expect(faceMock.create).toHaveBeenCalledWith({
         personId: 'person-1',
@@ -204,11 +204,11 @@ describe(FacialRecognitionService.name, () => {
       machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
       searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
       personMock.create.mockResolvedValue(personStub.noName);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
-      await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
+      await sut.handleRecognizeFaces({ id: assetStub.image.id });
 
-      expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
+      expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetStub.image.ownerId });
       expect(faceMock.create).toHaveBeenCalledWith({
         personId: 'person-1',
         assetId: 'asset-id',
@@ -254,7 +254,7 @@ describe(FacialRecognitionService.name, () => {
     });
 
     it('should skip an asset without a thumbnail', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
 
       await sut.handleGenerateFaceThumbnail(face.middle);
 
@@ -262,7 +262,7 @@ describe(FacialRecognitionService.name, () => {
     });
 
     it('should generate a thumbnail', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       await sut.handleGenerateFaceThumbnail(face.middle);
 
@@ -285,7 +285,7 @@ describe(FacialRecognitionService.name, () => {
     });
 
     it('should generate a thumbnail without going negative', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       await sut.handleGenerateFaceThumbnail(face.start);
 
@@ -302,7 +302,7 @@ describe(FacialRecognitionService.name, () => {
     });
 
     it('should generate a thumbnail without overflowing', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       await sut.handleGenerateFaceThumbnail(face.end);
 

+ 2 - 2
server/src/domain/job/job.service.spec.ts

@@ -1,7 +1,7 @@
 import { SystemConfig } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
 import {
-  assetEntityStub,
+  assetStub,
   asyncTick,
   newAssetRepositoryMock,
   newCommunicationRepositoryMock,
@@ -300,7 +300,7 @@ describe(JobService.name, () => {
     for (const { item, jobs } of tests) {
       it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
         if (item.name === JobName.GENERATE_JPEG_THUMBNAIL && item.data.source === 'upload') {
-          assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
+          assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
         } else {
           assetMock.getByIds.mockResolvedValue([]);
         }

+ 78 - 78
server/src/domain/media/media.service.spec.ts

@@ -1,6 +1,6 @@
 import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities';
 import {
-  assetEntityStub,
+  assetStub,
   newAssetRepositoryMock,
   newJobRepositoryMock,
   newMediaRepositoryMock,
@@ -40,7 +40,7 @@ describe(MediaService.name, () => {
   describe('handleQueueGenerateThumbnails', () => {
     it('should queue all assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
@@ -50,13 +50,13 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).not.toHaveBeenCalled();
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.GENERATE_JPEG_THUMBNAIL,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
 
     it('should queue all assets with missing resize path', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.noResizePath],
+        items: [assetStub.noResizePath],
         hasNextPage: false,
       });
 
@@ -66,13 +66,13 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.GENERATE_JPEG_THUMBNAIL,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
 
     it('should queue all assets with missing webp path', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.noWebpPath],
+        items: [assetStub.noWebpPath],
         hasNextPage: false,
       });
 
@@ -82,13 +82,13 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.GENERATE_WEBP_THUMBNAIL,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
 
     it('should queue all assets with missing thumbhash', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.noThumbhash],
+        items: [assetStub.noThumbhash],
         hasNextPage: false,
       });
 
@@ -98,7 +98,7 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
   });
@@ -106,14 +106,14 @@ describe(MediaService.name, () => {
   describe('handleGenerateJpegThumbnail', () => {
     it('should skip thumbnail generation if asset not found', async () => {
       assetMock.getByIds.mockResolvedValue([]);
-      await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
+      await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
       expect(mediaMock.resize).not.toHaveBeenCalled();
       expect(assetMock.save).not.toHaveBeenCalledWith();
     });
 
     it('should generate a thumbnail for an image', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
 
       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
       expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', {
@@ -127,8 +127,8 @@ describe(MediaService.name, () => {
     });
 
     it('should generate a thumbnail for a video', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleGenerateJpegThumbnail({ id: assetStub.video.id });
 
       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
       expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
@@ -143,28 +143,28 @@ describe(MediaService.name, () => {
     });
 
     it('should run successfully', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
     });
   });
 
   describe('handleGenerateWebpThumbnail', () => {
     it('should skip thumbnail generation if asset not found', async () => {
       assetMock.getByIds.mockResolvedValue([]);
-      await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id });
+      await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
       expect(mediaMock.resize).not.toHaveBeenCalled();
       expect(assetMock.save).not.toHaveBeenCalledWith();
     });
 
     it('should skip thumbnail generate if resize path is missing', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
-      await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.noResizePath.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
+      await sut.handleGenerateWebpThumbnail({ id: assetStub.noResizePath.id });
       expect(mediaMock.resize).not.toHaveBeenCalled();
     });
 
     it('should generate a thumbnail', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
 
       expect(mediaMock.resize).toHaveBeenCalledWith(
         '/uploads/user-id/thumbs/path.jpg',
@@ -178,22 +178,22 @@ describe(MediaService.name, () => {
   describe('handleGenerateThumbhashThumbnail', () => {
     it('should skip thumbhash generation if asset not found', async () => {
       assetMock.getByIds.mockResolvedValue([]);
-      await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
+      await sut.handleGenerateThumbhashThumbnail({ id: assetStub.image.id });
       expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
     });
 
     it('should skip thumbhash generation if resize path is missing', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
-      await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
+      await sut.handleGenerateThumbhashThumbnail({ id: assetStub.noResizePath.id });
       expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
     });
 
     it('should generate a thumbhash', async () => {
       const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
       mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
 
-      await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
+      await sut.handleGenerateThumbhashThumbnail({ id: assetStub.image.id });
 
       expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
       expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -203,7 +203,7 @@ describe(MediaService.name, () => {
   describe('handleQueueVideoConversion', () => {
     it('should queue all video assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.video],
+        items: [assetStub.video],
         hasNextPage: false,
       });
 
@@ -213,13 +213,13 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).not.toHaveBeenCalled();
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.VIDEO_CONVERSION,
-        data: { id: assetEntityStub.video.id },
+        data: { id: assetStub.video.id },
       });
     });
 
     it('should queue all video assets without encoded videos', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.video],
+        items: [assetStub.video],
         hasNextPage: false,
       });
 
@@ -229,35 +229,35 @@ describe(MediaService.name, () => {
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.VIDEO_CONVERSION,
-        data: { id: assetEntityStub.video.id },
+        data: { id: assetStub.video.id },
       });
     });
   });
 
   describe('handleVideoConversion', () => {
     beforeEach(() => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
     });
 
     it('should skip transcoding if asset not found', async () => {
       assetMock.getByIds.mockResolvedValue([]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.probe).not.toHaveBeenCalled();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should skip transcoding if non-video asset', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      await sut.handleVideoConversion({ id: assetEntityStub.image.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      await sut.handleVideoConversion({ id: assetStub.image.id });
       expect(mediaMock.probe).not.toHaveBeenCalled();
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should transcode the longest stream', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
       mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
 
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      await sut.handleVideoConversion({ id: assetStub.video.id });
 
       expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
       expect(configMock.load).toHaveBeenCalled();
@@ -282,23 +282,23 @@ describe(MediaService.name, () => {
 
     it('should skip a video without any streams', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should skip a video without any height', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.noHeight);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should transcode when set to all', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -320,7 +320,7 @@ describe(MediaService.name, () => {
     it('should transcode when optimal and too big', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -346,7 +346,7 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL },
         { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' },
       ]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -368,8 +368,8 @@ describe(MediaService.name, () => {
     it('should transcode with alternate scaling video is vertical', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -392,8 +392,8 @@ describe(MediaService.name, () => {
     it('should transcode when audio doesnt match target', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -416,8 +416,8 @@ describe(MediaService.name, () => {
     it('should transcode when container doesnt match target', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -440,32 +440,32 @@ describe(MediaService.name, () => {
     it('should not transcode an invalid transcode value', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should not transcode if transcoding is disabled', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should not transcode if target codec is invalid', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).not.toHaveBeenCalled();
     });
 
     it('should set max bitrate if above 0', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -493,8 +493,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
         { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -519,8 +519,8 @@ describe(MediaService.name, () => {
     it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -547,8 +547,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -577,8 +577,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
         { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -606,8 +606,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
         { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -634,8 +634,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
         { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -661,8 +661,8 @@ describe(MediaService.name, () => {
     it('should disable thread pooling for h264 if thread limit is above 0', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -688,8 +688,8 @@ describe(MediaService.name, () => {
     it('should omit thread flags for h264 if thread limit is at or below 0', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
       configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -715,8 +715,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',
@@ -745,8 +745,8 @@ describe(MediaService.name, () => {
         { key: SystemConfigKey.FFMPEG_THREADS, value: 0 },
         { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
       ]);
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      await sut.handleVideoConversion({ id: assetEntityStub.video.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      await sut.handleVideoConversion({ id: assetStub.video.id });
       expect(mediaMock.transcode).toHaveBeenCalledWith(
         '/original/path.ext',
         'upload/encoded-video/user-id/asset-id.mp4',

+ 19 - 19
server/src/domain/metadata/metadata.service.spec.ts

@@ -1,4 +1,4 @@
-import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock } from '@test';
+import { assetStub, newAssetRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock } from '@test';
 import { constants } from 'fs/promises';
 import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
 import { IJobRepository, JobName } from '../job';
@@ -25,7 +25,7 @@ describe(MetadataService.name, () => {
 
   describe('handleQueueSidecar', () => {
     it('should queue assets with sidecar files', async () => {
-      assetMock.getWith.mockResolvedValue({ items: [assetEntityStub.sidecar], hasNextPage: false });
+      assetMock.getWith.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false });
 
       await sut.handleQueueSidecar({ force: true });
 
@@ -33,12 +33,12 @@ describe(MetadataService.name, () => {
       expect(assetMock.getWithout).not.toHaveBeenCalled();
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SIDECAR_SYNC,
-        data: { id: assetEntityStub.sidecar.id },
+        data: { id: assetStub.sidecar.id },
       });
     });
 
     it('should queue assets without sidecar files', async () => {
-      assetMock.getWithout.mockResolvedValue({ items: [assetEntityStub.image], hasNextPage: false });
+      assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
 
       await sut.handleQueueSidecar({ force: false });
 
@@ -46,7 +46,7 @@ describe(MetadataService.name, () => {
       expect(assetMock.getWith).not.toHaveBeenCalled();
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SIDECAR_DISCOVERY,
-        data: { id: assetEntityStub.image.id },
+        data: { id: assetStub.image.id },
       });
     });
   });
@@ -59,44 +59,44 @@ describe(MetadataService.name, () => {
 
   describe('handleSidecarDiscovery', () => {
     it('should skip hidden assets', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
-      await sut.handleSidecarDiscovery({ id: assetEntityStub.livePhotoMotionAsset.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
+      await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
       expect(storageMock.checkFileExists).not.toHaveBeenCalled();
     });
 
     it('should skip assets with a sidecar path', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.sidecar]);
-      await sut.handleSidecarDiscovery({ id: assetEntityStub.sidecar.id });
+      assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
+      await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id });
       expect(storageMock.checkFileExists).not.toHaveBeenCalled();
     });
 
     it('should do nothing when a sidecar is not found ', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
       storageMock.checkFileExists.mockResolvedValue(false);
-      await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
+      await sut.handleSidecarDiscovery({ id: assetStub.image.id });
       expect(assetMock.save).not.toHaveBeenCalled();
     });
 
     it('should update a image asset when a sidecar is found', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
-      assetMock.save.mockResolvedValue(assetEntityStub.image);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      assetMock.save.mockResolvedValue(assetStub.image);
       storageMock.checkFileExists.mockResolvedValue(true);
-      await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
+      await sut.handleSidecarDiscovery({ id: assetStub.image.id });
       expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         sidecarPath: '/original/path.jpg.xmp',
       });
     });
 
     it('should update a video asset when a sidecar is found', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
-      assetMock.save.mockResolvedValue(assetEntityStub.video);
+      assetMock.getByIds.mockResolvedValue([assetStub.video]);
+      assetMock.save.mockResolvedValue(assetStub.video);
       storageMock.checkFileExists.mockResolvedValue(true);
-      await sut.handleSidecarDiscovery({ id: assetEntityStub.video.id });
+      await sut.handleSidecarDiscovery({ id: assetStub.video.id });
       expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         sidecarPath: '/original/path.ext.xmp',
       });
     });

+ 9 - 9
server/src/domain/person/person.service.spec.ts

@@ -1,6 +1,6 @@
 import { BadRequestException, NotFoundException } from '@nestjs/common';
 import {
-  assetEntityStub,
+  assetStub,
   authStub,
   faceStub,
   newJobRepositoryMock,
@@ -112,7 +112,7 @@ describe(PersonService.name, () => {
 
   describe('getAssets', () => {
     it("should return a person's assets", async () => {
-      personMock.getAssets.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]);
+      personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
       await sut.getAssets(authStub.admin, 'person-1');
       expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1');
     });
@@ -130,7 +130,7 @@ describe(PersonService.name, () => {
     it("should update a person's name", async () => {
       personMock.getById.mockResolvedValue(personStub.noName);
       personMock.update.mockResolvedValue(personStub.withName);
-      personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
+      personMock.getAssets.mockResolvedValue([assetStub.image]);
 
       await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
 
@@ -138,14 +138,14 @@ describe(PersonService.name, () => {
       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SEARCH_INDEX_ASSET,
-        data: { ids: [assetEntityStub.image.id] },
+        data: { ids: [assetStub.image.id] },
       });
     });
 
     it('should update a person visibility', async () => {
       personMock.getById.mockResolvedValue(personStub.hidden);
       personMock.update.mockResolvedValue(personStub.withName);
-      personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
+      personMock.getAssets.mockResolvedValue([assetStub.image]);
 
       await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
 
@@ -153,7 +153,7 @@ describe(PersonService.name, () => {
       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SEARCH_INDEX_ASSET,
-        data: { ids: [assetEntityStub.image.id] },
+        data: { ids: [assetStub.image.id] },
       });
     });
 
@@ -239,7 +239,7 @@ describe(PersonService.name, () => {
     it('should delete conflicting faces before merging', async () => {
       personMock.getById.mockResolvedValue(personStub.primaryPerson);
       personMock.getById.mockResolvedValue(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
+      personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
 
       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
         { id: 'person-2', success: true },
@@ -252,7 +252,7 @@ describe(PersonService.name, () => {
 
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SEARCH_REMOVE_FACE,
-        data: { assetId: assetEntityStub.image.id, personId: personStub.mergePerson.id },
+        data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
       });
     });
 
@@ -282,7 +282,7 @@ describe(PersonService.name, () => {
     it('should handle an error reassigning faces', async () => {
       personMock.getById.mockResolvedValue(personStub.primaryPerson);
       personMock.getById.mockResolvedValue(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
+      personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
       personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
 
       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([

+ 7 - 7
server/src/domain/search/search.service.spec.ts

@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import {
   albumStub,
-  assetEntityStub,
+  assetStub,
   asyncTick,
   authStub,
   faceStub,
@@ -192,14 +192,14 @@ describe(SearchService.name, () => {
 
     it('should index all the assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
       await sut.handleIndexAssets();
 
       expect(searchMock.importAssets.mock.calls).toEqual([
-        [[assetEntityStub.image], false],
+        [[assetStub.image], false],
         [[], true],
       ]);
     });
@@ -217,11 +217,11 @@ describe(SearchService.name, () => {
   describe('handleIndexAsset', () => {
     it('should skip if search is disabled', () => {
       const sut = makeSut('false');
-      sut.handleIndexAsset({ ids: [assetEntityStub.image.id] });
+      sut.handleIndexAsset({ ids: [assetStub.image.id] });
     });
 
     it('should index the asset', () => {
-      sut.handleIndexAsset({ ids: [assetEntityStub.image.id] });
+      sut.handleIndexAsset({ ids: [assetStub.image.id] });
     });
   });
 
@@ -367,7 +367,7 @@ describe(SearchService.name, () => {
     });
 
     it('should flush queued asset updates', async () => {
-      assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       sut.handleIndexAsset({ ids: ['asset1'] });
 
@@ -376,7 +376,7 @@ describe(SearchService.name, () => {
       await asyncTick(4);
 
       expect(assetMock.getByIds).toHaveBeenCalledWith(['asset1']);
-      expect(searchMock.importAssets).toHaveBeenCalledWith([assetEntityStub.image], false);
+      expect(searchMock.importAssets).toHaveBeenCalledWith([assetStub.image], false);
     });
 
     it('should flush queued asset deletes', async () => {

+ 9 - 9
server/src/domain/shared-link/shared-link.service.spec.ts

@@ -1,7 +1,7 @@
 import { BadRequestException, ForbiddenException } from '@nestjs/common';
 import {
   albumStub,
-  assetEntityStub,
+  assetStub,
   authStub,
   IAccessRepositoryMock,
   newAccessRepositoryMock,
@@ -136,20 +136,20 @@ describe(SharedLinkService.name, () => {
 
       await sut.create(authStub.admin, {
         type: SharedLinkType.INDIVIDUAL,
-        assetIds: [assetEntityStub.image.id],
+        assetIds: [assetStub.image.id],
         showExif: true,
         allowDownload: true,
         allowUpload: true,
       });
 
-      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
       expect(shareMock.create).toHaveBeenCalledWith({
         type: SharedLinkType.INDIVIDUAL,
         userId: authStub.admin.id,
         albumId: null,
         allowDownload: true,
         allowUpload: true,
-        assets: [{ id: assetEntityStub.image.id }],
+        assets: [{ id: assetStub.image.id }],
         description: null,
         expiresAt: null,
         showExif: true,
@@ -211,9 +211,9 @@ describe(SharedLinkService.name, () => {
       when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
 
       await expect(
-        sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
+        sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }),
       ).resolves.toEqual([
-        { assetId: assetEntityStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
+        { assetId: assetStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
         { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION },
         { assetId: 'asset-3', success: true },
       ]);
@@ -221,7 +221,7 @@ describe(SharedLinkService.name, () => {
       expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2);
       expect(shareMock.update).toHaveBeenCalledWith({
         ...sharedLinkStub.individual,
-        assets: [assetEntityStub.image, { id: 'asset-3' }],
+        assets: [assetStub.image, { id: 'asset-3' }],
       });
     });
   });
@@ -239,9 +239,9 @@ describe(SharedLinkService.name, () => {
       shareMock.create.mockResolvedValue(sharedLinkStub.individual);
 
       await expect(
-        sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2'] }),
+        sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }),
       ).resolves.toEqual([
-        { assetId: assetEntityStub.image.id, success: true },
+        { assetId: assetStub.image.id, success: true },
         { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
       ]);
 

+ 9 - 13
server/src/domain/smart-info/smart-info.service.spec.ts

@@ -1,6 +1,6 @@
 import { AssetEntity } from '@app/infra/entities';
 import {
-  assetEntityStub,
+  assetStub,
   newAssetRepositoryMock,
   newJobRepositoryMock,
   newMachineLearningRepositoryMock,
@@ -41,29 +41,25 @@ describe(SmartInfoService.name, () => {
   describe('handleQueueObjectTagging', () => {
     it('should queue the assets without tags', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
       await sut.handleQueueObjectTagging({ force: false });
 
-      expect(jobMock.queue.mock.calls).toEqual([
-        [{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
-      ]);
+      expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetStub.image.id } }]]);
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
     });
 
     it('should queue all the assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
       await sut.handleQueueObjectTagging({ force: true });
 
-      expect(jobMock.queue.mock.calls).toEqual([
-        [{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
-      ]);
+      expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetStub.image.id } }]]);
       expect(assetMock.getAll).toHaveBeenCalled();
     });
   });
@@ -104,25 +100,25 @@ describe(SmartInfoService.name, () => {
   describe('handleQueueEncodeClip', () => {
     it('should queue the assets without clip embeddings', async () => {
       assetMock.getWithout.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
       await sut.handleQueueEncodeClip({ force: false });
 
-      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } });
       expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING);
     });
 
     it('should queue all the assets', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
 
       await sut.handleQueueEncodeClip({ force: true });
 
-      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } });
       expect(assetMock.getAll).toHaveBeenCalled();
     });
   });

+ 26 - 26
server/src/domain/storage-template/storage-template.service.spec.ts

@@ -1,10 +1,10 @@
 import {
-  assetEntityStub,
+  assetStub,
   newAssetRepositoryMock,
   newStorageRepositoryMock,
   newSystemConfigRepositoryMock,
   newUserRepositoryMock,
-  userEntityStub,
+  userStub,
 } from '@test';
 import { when } from 'jest-when';
 import { StorageTemplateService } from '.';
@@ -49,11 +49,11 @@ describe(StorageTemplateService.name, () => {
 
     it('should handle an asset with a duplicate destination', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
-      assetMock.save.mockResolvedValue(assetEntityStub.image);
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      assetMock.save.mockResolvedValue(assetStub.image);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       when(storageMock.checkFileExists)
         .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
@@ -68,7 +68,7 @@ describe(StorageTemplateService.name, () => {
       expect(assetMock.getAll).toHaveBeenCalled();
       expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
       });
       expect(userMock.getList).toHaveBeenCalled();
@@ -78,13 +78,13 @@ describe(StorageTemplateService.name, () => {
       assetMock.getAll.mockResolvedValue({
         items: [
           {
-            ...assetEntityStub.image,
+            ...assetStub.image,
             originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
           },
         ],
         hasNextPage: false,
       });
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 
@@ -98,13 +98,13 @@ describe(StorageTemplateService.name, () => {
       assetMock.getAll.mockResolvedValue({
         items: [
           {
-            ...assetEntityStub.image,
+            ...assetStub.image,
             originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
           },
         ],
         hasNextPage: false,
       });
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 
@@ -116,11 +116,11 @@ describe(StorageTemplateService.name, () => {
 
     it('should move an asset', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
-      assetMock.save.mockResolvedValue(assetEntityStub.image);
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      assetMock.save.mockResolvedValue(assetStub.image);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 
@@ -130,18 +130,18 @@ describe(StorageTemplateService.name, () => {
         'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
       );
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
       });
     });
 
     it('should use the user storage label', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
-      assetMock.save.mockResolvedValue(assetEntityStub.image);
-      userMock.getList.mockResolvedValue([userEntityStub.storageLabel]);
+      assetMock.save.mockResolvedValue(assetStub.image);
+      userMock.getList.mockResolvedValue([userStub.storageLabel]);
 
       await sut.handleMigration();
 
@@ -151,18 +151,18 @@ describe(StorageTemplateService.name, () => {
         'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
       );
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
       });
     });
 
     it('should not update the database if the move fails', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
       storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 
@@ -176,17 +176,17 @@ describe(StorageTemplateService.name, () => {
 
     it('should move the asset back if the database fails', async () => {
       assetMock.getAll.mockResolvedValue({
-        items: [assetEntityStub.image],
+        items: [assetStub.image],
         hasNextPage: false,
       });
       assetMock.save.mockRejectedValue('Connection Error!');
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 
       expect(assetMock.getAll).toHaveBeenCalled();
       expect(assetMock.save).toHaveBeenCalledWith({
-        id: assetEntityStub.image.id,
+        id: assetStub.image.id,
         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
       });
       expect(storageMock.moveFile.mock.calls).toEqual([
@@ -199,15 +199,15 @@ describe(StorageTemplateService.name, () => {
       assetMock.getAll.mockResolvedValue({
         items: [
           {
-            ...assetEntityStub.image,
+            ...assetStub.image,
             originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
             isReadOnly: true,
           },
         ],
         hasNextPage: false,
       });
-      assetMock.save.mockResolvedValue(assetEntityStub.image);
-      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+      assetMock.save.mockResolvedValue(assetStub.image);
+      userMock.getList.mockResolvedValue([userStub.user1]);
 
       await sut.handleMigration();
 

+ 2 - 2
server/src/domain/tag/tag.service.spec.ts

@@ -1,6 +1,6 @@
 import { TagType } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
-import { assetEntityStub, authStub, newTagRepositoryMock, tagResponseStub, tagStub } from '@test';
+import { assetStub, authStub, newTagRepositoryMock, tagResponseStub, tagStub } from '@test';
 import { when } from 'jest-when';
 import { AssetIdErrorReason } from '../asset';
 import { ITagRepository } from './tag.repository';
@@ -107,7 +107,7 @@ describe(TagService.name, () => {
 
     it('should get the assets for a tag', async () => {
       tagMock.getById.mockResolvedValue(tagStub.tag1);
-      tagMock.getAssets.mockResolvedValue([assetEntityStub.image]);
+      tagMock.getAssets.mockResolvedValue([assetStub.image]);
       await sut.getAssets(authStub.admin, 'tag-1');
       expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');
       expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.id, 'tag-1');

+ 3 - 3
server/src/immich/api-v1/album/album.service.spec.ts

@@ -1,7 +1,7 @@
 import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain';
 import { AlbumEntity, UserEntity } from '@app/infra/entities';
 import { ForbiddenException, NotFoundException } from '@nestjs/common';
-import { userEntityStub } from '@test';
+import { userStub } from '@test';
 import { IAlbumRepository } from './album-repository';
 import { AlbumService } from './album.service';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -61,11 +61,11 @@ describe('Album service', () => {
     albumEntity.albumThumbnailAssetId = null;
     albumEntity.sharedUsers = [
       {
-        ...userEntityStub.user1,
+        ...userStub.user1,
         id: authUser.id,
       },
       {
-        ...userEntityStub.user1,
+        ...userStub.user1,
         id: sharedAlbumSharedAlsoWithId,
       },
     ];

+ 28 - 30
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -2,7 +2,7 @@ import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
 import {
-  assetEntityStub,
+  assetStub,
   authStub,
   fileStub,
   IAccessRepositoryMock,
@@ -132,11 +132,11 @@ describe('AssetService', () => {
     sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock);
 
     when(assetRepositoryMock.get)
-      .calledWith(assetEntityStub.livePhotoStillAsset.id)
-      .mockResolvedValue(assetEntityStub.livePhotoStillAsset);
+      .calledWith(assetStub.livePhotoStillAsset.id)
+      .mockResolvedValue(assetStub.livePhotoStillAsset);
     when(assetRepositoryMock.get)
-      .calledWith(assetEntityStub.livePhotoMotionAsset.id)
-      .mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
+      .calledWith(assetStub.livePhotoMotionAsset.id)
+      .mockResolvedValue(assetStub.livePhotoMotionAsset);
   });
 
   describe('uploadFile', () => {
@@ -185,8 +185,8 @@ describe('AssetService', () => {
       const error = new QueryFailedError('', [], '');
       (error as any).constraint = 'UQ_userid_checksum';
 
-      assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset);
-      assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset);
+      assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
+      assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
 
       await expect(
         sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
@@ -199,10 +199,10 @@ describe('AssetService', () => {
         [
           {
             name: JobName.METADATA_EXTRACTION,
-            data: { id: assetEntityStub.livePhotoMotionAsset.id, source: 'upload' },
+            data: { id: assetStub.livePhotoMotionAsset.id, source: 'upload' },
           },
         ],
-        [{ name: JobName.METADATA_EXTRACTION, data: { id: assetEntityStub.livePhotoStillAsset.id, source: 'upload' } }],
+        [{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
       ]);
     });
   });
@@ -263,9 +263,9 @@ describe('AssetService', () => {
     it('should delete a live photo', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
 
-      await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
-        { id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
-        { id: assetEntityStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
+      await expect(sut.deleteAll(authStub.user1, { ids: [assetStub.livePhotoStillAsset.id] })).resolves.toEqual([
+        { id: assetStub.livePhotoStillAsset.id, status: 'SUCCESS' },
+        { id: assetStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
       ]);
 
       expect(jobMock.queue).toHaveBeenCalledWith({
@@ -373,7 +373,7 @@ describe('AssetService', () => {
 
   describe('importFile', () => {
     it('should handle a file import', async () => {
-      assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
+      assetRepositoryMock.create.mockResolvedValue(assetStub.image);
       storageMock.checkFileExists.mockResolvedValue(true);
 
       await expect(
@@ -392,7 +392,7 @@ describe('AssetService', () => {
       (error as any).constraint = 'UQ_userid_checksum';
 
       assetRepositoryMock.create.mockRejectedValue(error);
-      assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
+      assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetStub.image]);
       storageMock.checkFileExists.mockResolvedValue(true);
       cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
 
@@ -411,36 +411,36 @@ describe('AssetService', () => {
   describe('getAssetById', () => {
     it('should allow owner access', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-      assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
-      await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
+      await sut.getAssetById(authStub.admin, assetStub.image.id);
+      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
     });
 
     it('should allow shared link access', async () => {
       accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
-      assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
-      await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
+      assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
+      await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id);
       expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
         authStub.adminSharedLink.sharedLinkId,
-        assetEntityStub.image.id,
+        assetStub.image.id,
       );
     });
 
     it('should allow partner sharing access', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
-      assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
-      await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
+      await sut.getAssetById(authStub.admin, assetStub.image.id);
+      expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
     });
 
     it('should allow shared album access', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
       accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
-      assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
-      await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
+      await sut.getAssetById(authStub.admin, assetStub.image.id);
+      expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
     });
 
     it('should throw an error for no access', async () => {
@@ -448,15 +448,13 @@ describe('AssetService', () => {
       accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
       accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
       accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
-      await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
+      await expect(sut.getAssetById(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
       expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
     });
 
     it('should throw an error for an invalid shared link', async () => {
       accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
-      await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
+      await expect(sut.getAssetById(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(
         BadRequestException,
       );
       expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();

+ 0 - 1262
server/test/fixtures.ts

@@ -1,1262 +0,0 @@
-import {
-  AlbumResponseDto,
-  AssetResponseDto,
-  AudioStreamInfo,
-  AuthUserDto,
-  ExifResponseDto,
-  mapUser,
-  SearchResult,
-  SharedLinkResponseDto,
-  TagResponseDto,
-  VideoFormat,
-  VideoInfo,
-  VideoStreamInfo,
-} from '@app/domain';
-import {
-  AlbumEntity,
-  APIKeyEntity,
-  AssetEntity,
-  AssetFaceEntity,
-  AssetType,
-  ExifEntity,
-  PartnerEntity,
-  PersonEntity,
-  SharedLinkEntity,
-  SharedLinkType,
-  SystemConfigEntity,
-  SystemConfigKey,
-  TagEntity,
-  TagType,
-  UserEntity,
-  UserTokenEntity,
-} from '@app/infra/entities';
-
-const today = new Date();
-const tomorrow = new Date();
-const yesterday = new Date();
-tomorrow.setDate(today.getDate() + 1);
-yesterday.setDate(yesterday.getDate() - 1);
-
-const sharedLinkBytes = Buffer.from(
-  '2c2b646895f84753bff43fb696ad124f3b0faf2a0bd547406f26fa4a76b5c71990092baa536275654b2ab7a191fb21a6d6cd',
-  'hex',
-);
-
-export const authStub = {
-  admin: Object.freeze<AuthUserDto>({
-    id: 'admin_id',
-    email: 'admin@test.com',
-    isAdmin: true,
-    isPublicUser: false,
-    isAllowUpload: true,
-    externalPath: null,
-  }),
-  user1: Object.freeze<AuthUserDto>({
-    id: 'user-id',
-    email: 'immich@test.com',
-    isAdmin: false,
-    isPublicUser: false,
-    isAllowUpload: true,
-    isAllowDownload: true,
-    isShowExif: true,
-    accessTokenId: 'token-id',
-    externalPath: null,
-  }),
-  user2: Object.freeze<AuthUserDto>({
-    id: 'user-2',
-    email: 'user2@immich.app',
-    isAdmin: false,
-    isPublicUser: false,
-    isAllowUpload: true,
-    isAllowDownload: true,
-    isShowExif: true,
-    accessTokenId: 'token-id',
-    externalPath: null,
-  }),
-  external1: Object.freeze<AuthUserDto>({
-    id: 'user-id',
-    email: 'immich@test.com',
-    isAdmin: false,
-    isPublicUser: false,
-    isAllowUpload: true,
-    isAllowDownload: true,
-    isShowExif: true,
-    accessTokenId: 'token-id',
-    externalPath: '/data/user1',
-  }),
-  adminSharedLink: Object.freeze<AuthUserDto>({
-    id: 'admin_id',
-    email: 'admin@test.com',
-    isAdmin: true,
-    isAllowUpload: true,
-    isAllowDownload: true,
-    isPublicUser: true,
-    isShowExif: true,
-    sharedLinkId: '123',
-  }),
-  adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
-    id: 'admin_id',
-    email: 'admin@test.com',
-    isAdmin: true,
-    isAllowUpload: true,
-    isAllowDownload: true,
-    isPublicUser: true,
-    isShowExif: false,
-    sharedLinkId: '123',
-  }),
-  readonlySharedLink: Object.freeze<AuthUserDto>({
-    id: 'admin_id',
-    email: 'admin@test.com',
-    isAdmin: true,
-    isAllowUpload: false,
-    isAllowDownload: false,
-    isPublicUser: true,
-    isShowExif: true,
-    sharedLinkId: '123',
-    accessTokenId: 'token-id',
-  }),
-};
-
-export const userEntityStub = {
-  admin: Object.freeze<UserEntity>({
-    ...authStub.admin,
-    password: 'admin_password',
-    firstName: 'admin_first_name',
-    lastName: 'admin_last_name',
-    storageLabel: 'admin',
-    externalPath: null,
-    oauthId: '',
-    shouldChangePassword: false,
-    profileImagePath: '',
-    createdAt: new Date('2021-01-01'),
-    deletedAt: null,
-    updatedAt: new Date('2021-01-01'),
-    tags: [],
-    assets: [],
-  }),
-  user1: Object.freeze<UserEntity>({
-    ...authStub.user1,
-    password: 'immich_password',
-    firstName: 'immich_first_name',
-    lastName: 'immich_last_name',
-    storageLabel: null,
-    externalPath: null,
-    oauthId: '',
-    shouldChangePassword: false,
-    profileImagePath: '',
-    createdAt: new Date('2021-01-01'),
-    deletedAt: null,
-    updatedAt: new Date('2021-01-01'),
-    tags: [],
-    assets: [],
-  }),
-  user2: Object.freeze<UserEntity>({
-    ...authStub.user2,
-    password: 'immich_password',
-    firstName: 'immich_first_name',
-    lastName: 'immich_last_name',
-    storageLabel: null,
-    externalPath: null,
-    oauthId: '',
-    shouldChangePassword: false,
-    profileImagePath: '',
-    createdAt: new Date('2021-01-01'),
-    deletedAt: null,
-    updatedAt: new Date('2021-01-01'),
-    tags: [],
-    assets: [],
-  }),
-  storageLabel: Object.freeze<UserEntity>({
-    ...authStub.user1,
-    password: 'immich_password',
-    firstName: 'immich_first_name',
-    lastName: 'immich_last_name',
-    storageLabel: 'label-1',
-    externalPath: null,
-    oauthId: '',
-    shouldChangePassword: false,
-    profileImagePath: '',
-    createdAt: new Date('2021-01-01'),
-    deletedAt: null,
-    updatedAt: new Date('2021-01-01'),
-    tags: [],
-    assets: [],
-  }),
-};
-
-export const fileStub = {
-  livePhotoStill: Object.freeze({
-    originalPath: 'fake_path/asset_1.jpeg',
-    checksum: Buffer.from('file hash', 'utf8'),
-    originalName: 'asset_1.jpeg',
-  }),
-  livePhotoMotion: Object.freeze({
-    originalPath: 'fake_path/asset_1.mp4',
-    checksum: Buffer.from('live photo file hash', 'utf8'),
-    originalName: 'asset_1.mp4',
-  }),
-};
-
-export const assetEntityStub = {
-  noResizePath: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    originalFileName: 'IMG_123',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: 'upload/library/IMG_123.jpg',
-    resizePath: null,
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: '/uploads/user-id/webp/path.ext',
-    thumbhash: Buffer.from('blablabla', 'base64'),
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    faces: [],
-    sidecarPath: null,
-    isReadOnly: false,
-  }),
-  noWebpPath: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: 'upload/library/IMG_456.jpg',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: null,
-    thumbhash: Buffer.from('blablabla', 'base64'),
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'IMG_456',
-    faces: [],
-    sidecarPath: null,
-    isReadOnly: false,
-    exifInfo: {
-      fileSizeInByte: 123_000,
-    } as ExifEntity,
-  }),
-  noThumbhash: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: '/uploads/user-id/webp/path.ext',
-    thumbhash: null,
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'asset-id.ext',
-    faces: [],
-    sidecarPath: null,
-  }),
-  image: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: '/original/path.jpg',
-    resizePath: '/uploads/user-id/thumbs/path.jpg',
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: '/uploads/user-id/webp/path.ext',
-    thumbhash: Buffer.from('blablabla', 'base64'),
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'asset-id.jpg',
-    faces: [],
-    sidecarPath: null,
-    exifInfo: {
-      fileSizeInByte: 5_000,
-    } as ExifEntity,
-  }),
-  image1: Object.freeze<AssetEntity>({
-    id: 'asset-id-1',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: '/uploads/user-id/webp/path.ext',
-    thumbhash: Buffer.from('blablabla', 'base64'),
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'asset-id.ext',
-    faces: [],
-    sidecarPath: null,
-    exifInfo: {
-      fileSizeInByte: 5_000,
-    } as ExifEntity,
-  }),
-  video: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    originalFileName: 'asset-id.ext',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.VIDEO,
-    webpPath: null,
-    thumbhash: null,
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    faces: [],
-    sidecarPath: null,
-    exifInfo: {
-      fileSizeInByte: 100_000,
-    } as ExifEntity,
-  }),
-  livePhotoMotionAsset: Object.freeze({
-    id: 'live-photo-motion-asset',
-    originalPath: fileStub.livePhotoMotion.originalPath,
-    ownerId: authStub.user1.id,
-    type: AssetType.VIDEO,
-    isVisible: false,
-    fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
-    fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
-    exifInfo: {
-      fileSizeInByte: 100_000,
-    },
-  } as AssetEntity),
-
-  livePhotoStillAsset: Object.freeze({
-    id: 'live-photo-still-asset',
-    originalPath: fileStub.livePhotoStill.originalPath,
-    ownerId: authStub.user1.id,
-    type: AssetType.IMAGE,
-    livePhotoVideoId: 'live-photo-motion-asset',
-    isVisible: true,
-    fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
-    fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
-    exifInfo: {
-      fileSizeInByte: 25_000,
-    },
-  } as AssetEntity),
-
-  withLocation: Object.freeze<AssetEntity>({
-    id: 'asset-with-favorite-id',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    checksum: Buffer.from('file hash', 'utf8'),
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    sidecarPath: null,
-    type: AssetType.IMAGE,
-    webpPath: null,
-    thumbhash: null,
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: false,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'asset-id.ext',
-    faces: [],
-    exifInfo: {
-      latitude: 100,
-      longitude: 100,
-      fileSizeInByte: 23_456,
-    } as ExifEntity,
-  }),
-  sidecar: Object.freeze<AssetEntity>({
-    id: 'asset-id',
-    deviceAssetId: 'device-asset-id',
-    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
-    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    owner: userEntityStub.user1,
-    ownerId: 'user-id',
-    deviceId: 'device-id',
-    originalPath: '/original/path.ext',
-    resizePath: '/uploads/user-id/thumbs/path.ext',
-    thumbhash: null,
-    checksum: Buffer.from('file hash', 'utf8'),
-    type: AssetType.IMAGE,
-    webpPath: null,
-    encodedVideoPath: null,
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    isFavorite: true,
-    isArchived: false,
-    isReadOnly: false,
-    duration: null,
-    isVisible: true,
-    livePhotoVideo: null,
-    livePhotoVideoId: null,
-    tags: [],
-    sharedLinks: [],
-    originalFileName: 'asset-id.ext',
-    faces: [],
-    sidecarPath: '/original/path.ext.xmp',
-  }),
-};
-
-export const albumStub = {
-  empty: Object.freeze<AlbumEntity>({
-    id: 'album-1',
-    albumName: 'Empty album',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-  sharedWithUser: Object.freeze<AlbumEntity>({
-    id: 'album-2',
-    albumName: 'Empty album shared with user',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [userEntityStub.user1],
-  }),
-  sharedWithMultiple: Object.freeze<AlbumEntity>({
-    id: 'album-3',
-    albumName: 'Empty album shared with users',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [userEntityStub.user1, userEntityStub.user2],
-  }),
-  sharedWithAdmin: Object.freeze<AlbumEntity>({
-    id: 'album-3',
-    albumName: 'Empty album shared with admin',
-    ownerId: authStub.user1.id,
-    owner: userEntityStub.user1,
-    assets: [],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [userEntityStub.admin],
-  }),
-  oneAsset: Object.freeze<AlbumEntity>({
-    id: 'album-4',
-    albumName: 'Album with one asset',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [assetEntityStub.image],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-  emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
-    id: 'album-5',
-    albumName: 'Empty album with invalid thumbnail',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [],
-    albumThumbnailAsset: assetEntityStub.image,
-    albumThumbnailAssetId: assetEntityStub.image.id,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-  emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
-    id: 'album-5',
-    albumName: 'Empty album with invalid thumbnail',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [],
-    albumThumbnailAsset: null,
-    albumThumbnailAssetId: null,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-  oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
-    id: 'album-6',
-    albumName: 'Album with one asset and invalid thumbnail',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [assetEntityStub.image],
-    albumThumbnailAsset: assetEntityStub.livePhotoMotionAsset,
-    albumThumbnailAssetId: assetEntityStub.livePhotoMotionAsset.id,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-  oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
-    id: 'album-6',
-    albumName: 'Album with one asset and invalid thumbnail',
-    ownerId: authStub.admin.id,
-    owner: userEntityStub.admin,
-    assets: [assetEntityStub.image],
-    albumThumbnailAsset: assetEntityStub.image,
-    albumThumbnailAssetId: assetEntityStub.image.id,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-    sharedLinks: [],
-    sharedUsers: [],
-  }),
-};
-
-const assetInfo: ExifResponseDto = {
-  make: 'camera-make',
-  model: 'camera-model',
-  exifImageWidth: 500,
-  exifImageHeight: 500,
-  fileSizeInByte: 100,
-  orientation: 'orientation',
-  dateTimeOriginal: today,
-  modifyDate: today,
-  timeZone: 'America/Los_Angeles',
-  lensModel: 'fancy',
-  fNumber: 100,
-  focalLength: 100,
-  iso: 100,
-  exposureTime: '1/16',
-  latitude: 100,
-  longitude: 100,
-  city: 'city',
-  state: 'state',
-  country: 'country',
-  description: 'description',
-  projectionType: null,
-};
-
-const assetResponse: AssetResponseDto = {
-  id: 'id_1',
-  deviceAssetId: 'device_asset_id_1',
-  ownerId: 'user_id_1',
-  deviceId: 'device_id_1',
-  type: AssetType.VIDEO,
-  originalPath: 'fake_path/jpeg',
-  originalFileName: 'asset_1.jpeg',
-  resized: false,
-  thumbhash: null,
-  fileModifiedAt: today,
-  fileCreatedAt: today,
-  updatedAt: today,
-  isFavorite: false,
-  isArchived: false,
-  smartInfo: {
-    tags: [],
-    objects: ['a', 'b', 'c'],
-  },
-  duration: '0:00:00.00000',
-  exifInfo: assetInfo,
-  livePhotoVideoId: null,
-  tags: [],
-  people: [],
-  checksum: 'ZmlsZSBoYXNo',
-};
-
-const albumResponse: AlbumResponseDto = {
-  albumName: 'Test Album',
-  albumThumbnailAssetId: null,
-  createdAt: today,
-  updatedAt: today,
-  id: 'album-123',
-  ownerId: 'admin_id',
-  owner: mapUser(userEntityStub.admin),
-  sharedUsers: [],
-  shared: false,
-  assets: [],
-  assetCount: 1,
-};
-
-export const userTokenEntityStub = {
-  userToken: Object.freeze<UserTokenEntity>({
-    id: 'token-id',
-    token: 'auth_token',
-    userId: userEntityStub.user1.id,
-    user: userEntityStub.user1,
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date(),
-    deviceType: '',
-    deviceOS: '',
-  }),
-  inactiveToken: Object.freeze<UserTokenEntity>({
-    id: 'not_active',
-    token: 'auth_token',
-    userId: userEntityStub.user1.id,
-    user: userEntityStub.user1,
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    deviceType: 'Mobile',
-    deviceOS: 'Android',
-  }),
-};
-
-export const keyStub = {
-  admin: Object.freeze({
-    id: 'my-random-guid',
-    name: 'My Key',
-    key: 'my-api-key (hashed)',
-    userId: authStub.admin.id,
-    user: userEntityStub.admin,
-  } as APIKeyEntity),
-};
-
-export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
-  defaults: [],
-  enabled: [
-    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
-    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
-    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
-    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
-  ],
-  disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }],
-  noAutoRegister: [
-    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
-    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
-    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false },
-    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
-  ],
-  override: [
-    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
-    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
-    { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true },
-    { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
-    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
-  ],
-};
-
-export const loginResponseStub = {
-  user1oauth: {
-    response: {
-      accessToken: 'cmFuZG9tLWJ5dGVz',
-      userId: 'user-id',
-      userEmail: 'immich@test.com',
-      firstName: 'immich_first_name',
-      lastName: 'immich_last_name',
-      profileImagePath: '',
-      isAdmin: false,
-      shouldChangePassword: false,
-    },
-    cookie: [
-      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
-      'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
-    ],
-  },
-  user1password: {
-    response: {
-      accessToken: 'cmFuZG9tLWJ5dGVz',
-      userId: 'user-id',
-      userEmail: 'immich@test.com',
-      firstName: 'immich_first_name',
-      lastName: 'immich_last_name',
-      profileImagePath: '',
-      isAdmin: false,
-      shouldChangePassword: false,
-    },
-    cookie: [
-      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
-      'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
-    ],
-  },
-  user1insecure: {
-    response: {
-      accessToken: 'cmFuZG9tLWJ5dGVz',
-      userId: 'user-id',
-      userEmail: 'immich@test.com',
-      firstName: 'immich_first_name',
-      lastName: 'immich_last_name',
-      profileImagePath: '',
-      isAdmin: false,
-      shouldChangePassword: false,
-    },
-    cookie: [
-      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
-      'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
-    ],
-  },
-};
-
-export const sharedLinkStub = {
-  individual: Object.freeze({
-    id: '123',
-    userId: authStub.admin.id,
-    user: userEntityStub.admin,
-    key: sharedLinkBytes,
-    type: SharedLinkType.INDIVIDUAL,
-    createdAt: today,
-    expiresAt: tomorrow,
-    allowUpload: true,
-    allowDownload: true,
-    showExif: true,
-    album: undefined,
-    description: null,
-    assets: [assetEntityStub.image],
-  } as SharedLinkEntity),
-  valid: Object.freeze({
-    id: '123',
-    userId: authStub.admin.id,
-    user: userEntityStub.admin,
-    key: sharedLinkBytes,
-    type: SharedLinkType.ALBUM,
-    createdAt: today,
-    expiresAt: tomorrow,
-    allowUpload: true,
-    allowDownload: true,
-    showExif: true,
-    album: undefined,
-    albumId: null,
-    description: null,
-    assets: [],
-  } as SharedLinkEntity),
-  expired: Object.freeze({
-    id: '123',
-    userId: authStub.admin.id,
-    user: userEntityStub.admin,
-    key: sharedLinkBytes,
-    type: SharedLinkType.ALBUM,
-    createdAt: today,
-    expiresAt: yesterday,
-    allowUpload: true,
-    allowDownload: true,
-    showExif: true,
-    description: null,
-    albumId: null,
-    assets: [],
-  } as SharedLinkEntity),
-  readonlyNoExif: Object.freeze<SharedLinkEntity>({
-    id: '123',
-    userId: authStub.admin.id,
-    user: userEntityStub.admin,
-    key: sharedLinkBytes,
-    type: SharedLinkType.ALBUM,
-    createdAt: today,
-    expiresAt: tomorrow,
-    allowUpload: false,
-    allowDownload: false,
-    showExif: false,
-    description: null,
-    assets: [],
-    albumId: 'album-123',
-    album: {
-      id: 'album-123',
-      ownerId: authStub.admin.id,
-      owner: userEntityStub.admin,
-      albumName: 'Test Album',
-      createdAt: today,
-      updatedAt: today,
-      albumThumbnailAsset: null,
-      albumThumbnailAssetId: null,
-      sharedUsers: [],
-      sharedLinks: [],
-      assets: [
-        {
-          id: 'id_1',
-          owner: userEntityStub.user1,
-          ownerId: 'user_id_1',
-          deviceAssetId: 'device_asset_id_1',
-          deviceId: 'device_id_1',
-          type: AssetType.VIDEO,
-          originalPath: 'fake_path/jpeg',
-          resizePath: '',
-          checksum: Buffer.from('file hash', 'utf8'),
-          fileModifiedAt: today,
-          fileCreatedAt: today,
-          createdAt: today,
-          updatedAt: today,
-          isFavorite: false,
-          isArchived: false,
-          isReadOnly: false,
-          smartInfo: {
-            assetId: 'id_1',
-            tags: [],
-            objects: ['a', 'b', 'c'],
-            asset: null as any,
-            clipEmbedding: [0.12, 0.13, 0.14],
-          },
-          webpPath: '',
-          thumbhash: null,
-          encodedVideoPath: '',
-          duration: null,
-          isVisible: true,
-          livePhotoVideo: null,
-          livePhotoVideoId: null,
-          originalFileName: 'asset_1.jpeg',
-          exifInfo: {
-            projectionType: null,
-            livePhotoCID: null,
-            assetId: 'id_1',
-            description: 'description',
-            exifImageWidth: 500,
-            exifImageHeight: 500,
-            fileSizeInByte: 100,
-            orientation: 'orientation',
-            dateTimeOriginal: today,
-            modifyDate: today,
-            timeZone: 'America/Los_Angeles',
-            latitude: 100,
-            longitude: 100,
-            city: 'city',
-            state: 'state',
-            country: 'country',
-            make: 'camera-make',
-            model: 'camera-model',
-            lensModel: 'fancy',
-            fNumber: 100,
-            focalLength: 100,
-            iso: 100,
-            exposureTime: '1/16',
-            fps: 100,
-            asset: null as any,
-            exifTextSearchableColumn: '',
-          },
-          tags: [],
-          sharedLinks: [],
-          faces: [],
-          sidecarPath: null,
-        },
-      ],
-    },
-  }),
-};
-
-export const sharedLinkResponseStub = {
-  valid: Object.freeze<SharedLinkResponseDto>({
-    allowDownload: true,
-    allowUpload: true,
-    assets: [],
-    createdAt: today,
-    description: null,
-    expiresAt: tomorrow,
-    id: '123',
-    key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
-    type: SharedLinkType.ALBUM,
-    userId: 'admin_id',
-  }),
-  expired: Object.freeze<SharedLinkResponseDto>({
-    album: undefined,
-    allowDownload: true,
-    allowUpload: true,
-    assets: [],
-    createdAt: today,
-    description: null,
-    expiresAt: yesterday,
-    id: '123',
-    key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
-    type: SharedLinkType.ALBUM,
-    userId: 'admin_id',
-  }),
-  readonly: Object.freeze<SharedLinkResponseDto>({
-    id: '123',
-    userId: 'admin_id',
-    key: sharedLinkBytes.toString('base64url'),
-    type: SharedLinkType.ALBUM,
-    createdAt: today,
-    expiresAt: tomorrow,
-    description: null,
-    allowUpload: false,
-    allowDownload: false,
-    showExif: true,
-    album: albumResponse,
-    assets: [assetResponse],
-  }),
-  readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
-    id: '123',
-    userId: 'admin_id',
-    key: sharedLinkBytes.toString('base64url'),
-    type: SharedLinkType.ALBUM,
-    createdAt: today,
-    expiresAt: tomorrow,
-    description: null,
-    allowUpload: false,
-    allowDownload: false,
-    showExif: false,
-    album: albumResponse,
-    assets: [{ ...assetResponse, exifInfo: undefined }],
-  }),
-};
-
-// TODO - the constructor isn't used anywhere, so not test coverage
-new ExifResponseDto();
-
-export const searchStub = {
-  emptyResults: Object.freeze<SearchResult<any>>({
-    total: 0,
-    count: 0,
-    page: 1,
-    items: [],
-    facets: [],
-    distances: [],
-  }),
-};
-
-const probeStubDefaultFormat: VideoFormat = {
-  formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
-  formatLongName: 'QuickTime / MOV',
-  duration: 0,
-};
-
-const probeStubDefaultVideoStream: VideoStreamInfo[] = [
-  { height: 1080, width: 1920, codecName: 'h265', codecType: 'video', frameCount: 100, rotation: 0 },
-];
-
-const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ codecName: 'aac', codecType: 'audio' }];
-
-const probeStubDefault: VideoInfo = {
-  format: probeStubDefaultFormat,
-  videoStreams: probeStubDefaultVideoStream,
-  audioStreams: probeStubDefaultAudioStream,
-};
-
-export const probeStub = {
-  noVideoStreams: Object.freeze<VideoInfo>({ ...probeStubDefault, videoStreams: [] }),
-  multipleVideoStreams: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    videoStreams: [
-      {
-        height: 1080,
-        width: 400,
-        codecName: 'h265',
-        codecType: 'video',
-        frameCount: 100,
-        rotation: 0,
-      },
-      {
-        height: 1080,
-        width: 400,
-        codecName: 'h7000',
-        codecType: 'video',
-        frameCount: 99,
-        rotation: 0,
-      },
-    ],
-  }),
-  noHeight: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    videoStreams: [
-      {
-        height: 0,
-        width: 400,
-        codecName: 'h265',
-        codecType: 'video',
-        frameCount: 100,
-        rotation: 0,
-      },
-    ],
-  }),
-  videoStream2160p: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    videoStreams: [
-      {
-        height: 2160,
-        width: 3840,
-        codecName: 'h264',
-        codecType: 'video',
-        frameCount: 100,
-        rotation: 0,
-      },
-    ],
-  }),
-  videoStreamVertical2160p: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    videoStreams: [
-      {
-        height: 2160,
-        width: 3840,
-        codecName: 'h264',
-        codecType: 'video',
-        frameCount: 100,
-        rotation: 90,
-      },
-    ],
-  }),
-  audioStreamMp3: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    audioStreams: [{ codecType: 'audio', codecName: 'aac' }],
-  }),
-  matroskaContainer: Object.freeze<VideoInfo>({
-    ...probeStubDefault,
-    format: {
-      formatName: 'matroska,webm',
-      formatLongName: 'Matroska / WebM',
-      duration: 0,
-    },
-  }),
-};
-
-export const personStub = {
-  noName: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: '',
-    thumbnailPath: '/path/to/thumbnail.jpg',
-    faces: [],
-    isHidden: false,
-  }),
-  hidden: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: '',
-    thumbnailPath: '/path/to/thumbnail.jpg',
-    faces: [],
-    isHidden: true,
-  }),
-  withName: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: 'Person 1',
-    thumbnailPath: '/path/to/thumbnail.jpg',
-    faces: [],
-    isHidden: false,
-  }),
-  noThumbnail: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: '',
-    thumbnailPath: '',
-    faces: [],
-    isHidden: false,
-  }),
-  newThumbnail: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: '',
-    thumbnailPath: '/new/path/to/thumbnail.jpg',
-    faces: [],
-    isHidden: false,
-  }),
-  primaryPerson: Object.freeze<PersonEntity>({
-    id: 'person-1',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: 'Person 1',
-    thumbnailPath: '/path/to/thumbnail',
-    faces: [],
-    isHidden: false,
-  }),
-  mergePerson: Object.freeze<PersonEntity>({
-    id: 'person-2',
-    createdAt: new Date('2021-01-01'),
-    updatedAt: new Date('2021-01-01'),
-    ownerId: userEntityStub.admin.id,
-    owner: userEntityStub.admin,
-    name: 'Person 2',
-    thumbnailPath: '/path/to/thumbnail',
-    faces: [],
-    isHidden: false,
-  }),
-};
-
-export const partnerStub = {
-  adminToUser1: Object.freeze<PartnerEntity>({
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    sharedById: userEntityStub.admin.id,
-    sharedBy: userEntityStub.admin,
-    sharedWith: userEntityStub.user1,
-    sharedWithId: userEntityStub.user1.id,
-  }),
-  user1ToAdmin1: Object.freeze<PartnerEntity>({
-    createdAt: new Date('2023-02-23T05:06:29.716Z'),
-    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
-    sharedBy: userEntityStub.user1,
-    sharedById: userEntityStub.user1.id,
-    sharedWithId: userEntityStub.admin.id,
-    sharedWith: userEntityStub.admin,
-  }),
-};
-
-export const faceStub = {
-  face1: Object.freeze<AssetFaceEntity>({
-    assetId: assetEntityStub.image.id,
-    asset: assetEntityStub.image,
-    personId: personStub.withName.id,
-    person: personStub.withName,
-    embedding: [1, 2, 3, 4],
-    boundingBoxX1: 0,
-    boundingBoxY1: 0,
-    boundingBoxX2: 1,
-    boundingBoxY2: 1,
-    imageHeight: 1024,
-    imageWidth: 1024,
-  }),
-  primaryFace1: Object.freeze<AssetFaceEntity>({
-    assetId: assetEntityStub.image.id,
-    asset: assetEntityStub.image,
-    personId: personStub.primaryPerson.id,
-    person: personStub.primaryPerson,
-    embedding: [1, 2, 3, 4],
-    boundingBoxX1: 0,
-    boundingBoxY1: 0,
-    boundingBoxX2: 1,
-    boundingBoxY2: 1,
-    imageHeight: 1024,
-    imageWidth: 1024,
-  }),
-  mergeFace1: Object.freeze<AssetFaceEntity>({
-    assetId: assetEntityStub.image.id,
-    asset: assetEntityStub.image,
-    personId: personStub.mergePerson.id,
-    person: personStub.mergePerson,
-    embedding: [1, 2, 3, 4],
-    boundingBoxX1: 0,
-    boundingBoxY1: 0,
-    boundingBoxX2: 1,
-    boundingBoxY2: 1,
-    imageHeight: 1024,
-    imageWidth: 1024,
-  }),
-  mergeFace2: Object.freeze<AssetFaceEntity>({
-    assetId: assetEntityStub.image1.id,
-    asset: assetEntityStub.image1,
-    personId: personStub.mergePerson.id,
-    person: personStub.mergePerson,
-    embedding: [1, 2, 3, 4],
-    boundingBoxX1: 0,
-    boundingBoxY1: 0,
-    boundingBoxX2: 1,
-    boundingBoxY2: 1,
-    imageHeight: 1024,
-    imageWidth: 1024,
-  }),
-};
-
-export const tagStub = {
-  tag1: Object.freeze<TagEntity>({
-    id: 'tag-1',
-    name: 'Tag1',
-    type: TagType.CUSTOM,
-    userId: userEntityStub.admin.id,
-    user: userEntityStub.admin,
-    renameTagId: null,
-    assets: [],
-  }),
-};
-
-export const tagResponseStub = {
-  tag1: Object.freeze<TagResponseDto>({
-    id: 'tag-1',
-    name: 'Tag1',
-    type: 'CUSTOM',
-    userId: 'admin_id',
-  }),
-};

+ 124 - 0
server/test/fixtures/album.stub.ts

@@ -0,0 +1,124 @@
+import { AlbumEntity } from '@app/infra/entities';
+import { assetStub } from './asset.stub';
+import { authStub } from './auth.stub';
+import { userStub } from './user.stub';
+
+export const albumStub = {
+  empty: Object.freeze<AlbumEntity>({
+    id: 'album-1',
+    albumName: 'Empty album',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+  sharedWithUser: Object.freeze<AlbumEntity>({
+    id: 'album-2',
+    albumName: 'Empty album shared with user',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [userStub.user1],
+  }),
+  sharedWithMultiple: Object.freeze<AlbumEntity>({
+    id: 'album-3',
+    albumName: 'Empty album shared with users',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [userStub.user1, userStub.user2],
+  }),
+  sharedWithAdmin: Object.freeze<AlbumEntity>({
+    id: 'album-3',
+    albumName: 'Empty album shared with admin',
+    ownerId: authStub.user1.id,
+    owner: userStub.user1,
+    assets: [],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [userStub.admin],
+  }),
+  oneAsset: Object.freeze<AlbumEntity>({
+    id: 'album-4',
+    albumName: 'Album with one asset',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [assetStub.image],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+  emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
+    id: 'album-5',
+    albumName: 'Empty album with invalid thumbnail',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [],
+    albumThumbnailAsset: assetStub.image,
+    albumThumbnailAssetId: assetStub.image.id,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+  emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
+    id: 'album-5',
+    albumName: 'Empty album with invalid thumbnail',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [],
+    albumThumbnailAsset: null,
+    albumThumbnailAssetId: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+  oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
+    id: 'album-6',
+    albumName: 'Album with one asset and invalid thumbnail',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [assetStub.image],
+    albumThumbnailAsset: assetStub.livePhotoMotionAsset,
+    albumThumbnailAssetId: assetStub.livePhotoMotionAsset.id,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+  oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
+    id: 'album-6',
+    albumName: 'Album with one asset and invalid thumbnail',
+    ownerId: authStub.admin.id,
+    owner: userStub.admin,
+    assets: [assetStub.image],
+    albumThumbnailAsset: assetStub.image,
+    albumThumbnailAssetId: assetStub.image.id,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    sharedLinks: [],
+    sharedUsers: [],
+  }),
+};

+ 13 - 0
server/test/fixtures/api-key.stub.ts

@@ -0,0 +1,13 @@
+import { APIKeyEntity } from '@app/infra/entities';
+import { authStub } from './auth.stub';
+import { userStub } from './user.stub';
+
+export const keyStub = {
+  admin: Object.freeze({
+    id: 'my-random-guid',
+    name: 'My Key',
+    key: 'my-api-key (hashed)',
+    userId: authStub.admin.id,
+    user: userStub.admin,
+  } as APIKeyEntity),
+};

+ 291 - 0
server/test/fixtures/asset.stub.ts

@@ -0,0 +1,291 @@
+import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
+import { authStub } from './auth.stub';
+import { fileStub } from './file.stub';
+import { userStub } from './user.stub';
+
+export const assetStub = {
+  noResizePath: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    originalFileName: 'IMG_123',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: 'upload/library/IMG_123.jpg',
+    resizePath: null,
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: '/uploads/user-id/webp/path.ext',
+    thumbhash: Buffer.from('blablabla', 'base64'),
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    faces: [],
+    sidecarPath: null,
+    isReadOnly: false,
+  }),
+  noWebpPath: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: 'upload/library/IMG_456.jpg',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: null,
+    thumbhash: Buffer.from('blablabla', 'base64'),
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'IMG_456',
+    faces: [],
+    sidecarPath: null,
+    isReadOnly: false,
+    exifInfo: {
+      fileSizeInByte: 123_000,
+    } as ExifEntity,
+  }),
+  noThumbhash: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: '/uploads/user-id/webp/path.ext',
+    thumbhash: null,
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    sidecarPath: null,
+  }),
+  image: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.jpg',
+    resizePath: '/uploads/user-id/thumbs/path.jpg',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: '/uploads/user-id/webp/path.ext',
+    thumbhash: Buffer.from('blablabla', 'base64'),
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.jpg',
+    faces: [],
+    sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 5_000,
+    } as ExifEntity,
+  }),
+  image1: Object.freeze<AssetEntity>({
+    id: 'asset-id-1',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: '/uploads/user-id/webp/path.ext',
+    thumbhash: Buffer.from('blablabla', 'base64'),
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 5_000,
+    } as ExifEntity,
+  }),
+  video: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    originalFileName: 'asset-id.ext',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.VIDEO,
+    webpPath: null,
+    thumbhash: null,
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    faces: [],
+    sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 100_000,
+    } as ExifEntity,
+  }),
+  livePhotoMotionAsset: Object.freeze({
+    id: 'live-photo-motion-asset',
+    originalPath: fileStub.livePhotoMotion.originalPath,
+    ownerId: authStub.user1.id,
+    type: AssetType.VIDEO,
+    isVisible: false,
+    fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
+    fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
+    exifInfo: {
+      fileSizeInByte: 100_000,
+    },
+  } as AssetEntity),
+
+  livePhotoStillAsset: Object.freeze({
+    id: 'live-photo-still-asset',
+    originalPath: fileStub.livePhotoStill.originalPath,
+    ownerId: authStub.user1.id,
+    type: AssetType.IMAGE,
+    livePhotoVideoId: 'live-photo-motion-asset',
+    isVisible: true,
+    fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
+    fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
+    exifInfo: {
+      fileSizeInByte: 25_000,
+    },
+  } as AssetEntity),
+
+  withLocation: Object.freeze<AssetEntity>({
+    id: 'asset-with-favorite-id',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    checksum: Buffer.from('file hash', 'utf8'),
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    sidecarPath: null,
+    type: AssetType.IMAGE,
+    webpPath: null,
+    thumbhash: null,
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: false,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    exifInfo: {
+      latitude: 100,
+      longitude: 100,
+      fileSizeInByte: 23_456,
+    } as ExifEntity,
+  }),
+  sidecar: Object.freeze<AssetEntity>({
+    id: 'asset-id',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    thumbhash: null,
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: null,
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    sidecarPath: '/original/path.ext.xmp',
+  }),
+};

+ 127 - 0
server/test/fixtures/auth.stub.ts

@@ -0,0 +1,127 @@
+import { AuthUserDto } from '@app/domain';
+
+export const authStub = {
+  admin: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isPublicUser: false,
+    isAllowUpload: true,
+    externalPath: null,
+  }),
+  user1: Object.freeze<AuthUserDto>({
+    id: 'user-id',
+    email: 'immich@test.com',
+    isAdmin: false,
+    isPublicUser: false,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isShowExif: true,
+    accessTokenId: 'token-id',
+    externalPath: null,
+  }),
+  user2: Object.freeze<AuthUserDto>({
+    id: 'user-2',
+    email: 'user2@immich.app',
+    isAdmin: false,
+    isPublicUser: false,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isShowExif: true,
+    accessTokenId: 'token-id',
+    externalPath: null,
+  }),
+  external1: Object.freeze<AuthUserDto>({
+    id: 'user-id',
+    email: 'immich@test.com',
+    isAdmin: false,
+    isPublicUser: false,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isShowExif: true,
+    accessTokenId: 'token-id',
+    externalPath: '/data/user1',
+  }),
+  adminSharedLink: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isPublicUser: true,
+    isShowExif: true,
+    sharedLinkId: '123',
+  }),
+  adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isPublicUser: true,
+    isShowExif: false,
+    sharedLinkId: '123',
+  }),
+  readonlySharedLink: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isAllowUpload: false,
+    isAllowDownload: false,
+    isPublicUser: true,
+    isShowExif: true,
+    sharedLinkId: '123',
+    accessTokenId: 'token-id',
+  }),
+};
+
+export const loginResponseStub = {
+  user1oauth: {
+    response: {
+      accessToken: 'cmFuZG9tLWJ5dGVz',
+      userId: 'user-id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
+      'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
+    ],
+  },
+  user1password: {
+    response: {
+      accessToken: 'cmFuZG9tLWJ5dGVz',
+      userId: 'user-id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
+      'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
+    ],
+  },
+  user1insecure: {
+    response: {
+      accessToken: 'cmFuZG9tLWJ5dGVz',
+      userId: 'user-id',
+      userEmail: 'immich@test.com',
+      firstName: 'immich_first_name',
+      lastName: 'immich_last_name',
+      profileImagePath: '',
+      isAdmin: false,
+      shouldChangePassword: false,
+    },
+    cookie: [
+      'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
+      'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
+    ],
+  },
+};

+ 58 - 0
server/test/fixtures/face.stub.ts

@@ -0,0 +1,58 @@
+import { AssetFaceEntity } from '@app/infra/entities';
+import { assetStub } from './asset.stub';
+import { personStub } from './person.stub';
+
+export const faceStub = {
+  face1: Object.freeze<AssetFaceEntity>({
+    assetId: assetStub.image.id,
+    asset: assetStub.image,
+    personId: personStub.withName.id,
+    person: personStub.withName,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+  primaryFace1: Object.freeze<AssetFaceEntity>({
+    assetId: assetStub.image.id,
+    asset: assetStub.image,
+    personId: personStub.primaryPerson.id,
+    person: personStub.primaryPerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+  mergeFace1: Object.freeze<AssetFaceEntity>({
+    assetId: assetStub.image.id,
+    asset: assetStub.image,
+    personId: personStub.mergePerson.id,
+    person: personStub.mergePerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+  mergeFace2: Object.freeze<AssetFaceEntity>({
+    assetId: assetStub.image1.id,
+    asset: assetStub.image1,
+    personId: personStub.mergePerson.id,
+    person: personStub.mergePerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+};

+ 12 - 0
server/test/fixtures/file.stub.ts

@@ -0,0 +1,12 @@
+export const fileStub = {
+  livePhotoStill: Object.freeze({
+    originalPath: 'fake_path/asset_1.jpeg',
+    checksum: Buffer.from('file hash', 'utf8'),
+    originalName: 'asset_1.jpeg',
+  }),
+  livePhotoMotion: Object.freeze({
+    originalPath: 'fake_path/asset_1.mp4',
+    checksum: Buffer.from('live photo file hash', 'utf8'),
+    originalName: 'asset_1.mp4',
+  }),
+};

+ 15 - 0
server/test/fixtures/index.ts

@@ -0,0 +1,15 @@
+export * from './album.stub';
+export * from './api-key.stub';
+export * from './asset.stub';
+export * from './auth.stub';
+export * from './face.stub';
+export * from './file.stub';
+export * from './media.stub';
+export * from './partner.stub';
+export * from './person.stub';
+export * from './search.stub';
+export * from './shared-link.stub';
+export * from './system-config.stub';
+export * from './tag.stub';
+export * from './user-token.stub';
+export * from './user.stub';

+ 95 - 0
server/test/fixtures/media.stub.ts

@@ -0,0 +1,95 @@
+import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from '@app/domain';
+
+const probeStubDefaultFormat: VideoFormat = {
+  formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
+  formatLongName: 'QuickTime / MOV',
+  duration: 0,
+};
+
+const probeStubDefaultVideoStream: VideoStreamInfo[] = [
+  { height: 1080, width: 1920, codecName: 'h265', codecType: 'video', frameCount: 100, rotation: 0 },
+];
+
+const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ codecName: 'aac', codecType: 'audio' }];
+
+const probeStubDefault: VideoInfo = {
+  format: probeStubDefaultFormat,
+  videoStreams: probeStubDefaultVideoStream,
+  audioStreams: probeStubDefaultAudioStream,
+};
+
+export const probeStub = {
+  noVideoStreams: Object.freeze<VideoInfo>({ ...probeStubDefault, videoStreams: [] }),
+  multipleVideoStreams: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    videoStreams: [
+      {
+        height: 1080,
+        width: 400,
+        codecName: 'h265',
+        codecType: 'video',
+        frameCount: 100,
+        rotation: 0,
+      },
+      {
+        height: 1080,
+        width: 400,
+        codecName: 'h7000',
+        codecType: 'video',
+        frameCount: 99,
+        rotation: 0,
+      },
+    ],
+  }),
+  noHeight: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    videoStreams: [
+      {
+        height: 0,
+        width: 400,
+        codecName: 'h265',
+        codecType: 'video',
+        frameCount: 100,
+        rotation: 0,
+      },
+    ],
+  }),
+  videoStream2160p: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    videoStreams: [
+      {
+        height: 2160,
+        width: 3840,
+        codecName: 'h264',
+        codecType: 'video',
+        frameCount: 100,
+        rotation: 0,
+      },
+    ],
+  }),
+  videoStreamVertical2160p: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    videoStreams: [
+      {
+        height: 2160,
+        width: 3840,
+        codecName: 'h264',
+        codecType: 'video',
+        frameCount: 100,
+        rotation: 90,
+      },
+    ],
+  }),
+  audioStreamMp3: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    audioStreams: [{ codecType: 'audio', codecName: 'aac' }],
+  }),
+  matroskaContainer: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    format: {
+      formatName: 'matroska,webm',
+      formatLongName: 'Matroska / WebM',
+      duration: 0,
+    },
+  }),
+};

+ 21 - 0
server/test/fixtures/partner.stub.ts

@@ -0,0 +1,21 @@
+import { PartnerEntity } from '@app/infra/entities';
+import { userStub } from './user.stub';
+
+export const partnerStub = {
+  adminToUser1: Object.freeze<PartnerEntity>({
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    sharedById: userStub.admin.id,
+    sharedBy: userStub.admin,
+    sharedWith: userStub.user1,
+    sharedWithId: userStub.user1.id,
+  }),
+  user1ToAdmin1: Object.freeze<PartnerEntity>({
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    sharedBy: userStub.user1,
+    sharedById: userStub.user1.id,
+    sharedWithId: userStub.admin.id,
+    sharedWith: userStub.admin,
+  }),
+};

+ 82 - 0
server/test/fixtures/person.stub.ts

@@ -0,0 +1,82 @@
+import { PersonEntity } from '@app/infra/entities';
+import { userStub } from './user.stub';
+
+export const personStub = {
+  noName: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: '',
+    thumbnailPath: '/path/to/thumbnail.jpg',
+    faces: [],
+    isHidden: false,
+  }),
+  hidden: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: '',
+    thumbnailPath: '/path/to/thumbnail.jpg',
+    faces: [],
+    isHidden: true,
+  }),
+  withName: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: 'Person 1',
+    thumbnailPath: '/path/to/thumbnail.jpg',
+    faces: [],
+    isHidden: false,
+  }),
+  noThumbnail: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: '',
+    thumbnailPath: '',
+    faces: [],
+    isHidden: false,
+  }),
+  newThumbnail: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: '',
+    thumbnailPath: '/new/path/to/thumbnail.jpg',
+    faces: [],
+    isHidden: false,
+  }),
+  primaryPerson: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: 'Person 1',
+    thumbnailPath: '/path/to/thumbnail',
+    faces: [],
+    isHidden: false,
+  }),
+  mergePerson: Object.freeze<PersonEntity>({
+    id: 'person-2',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userStub.admin.id,
+    owner: userStub.admin,
+    name: 'Person 2',
+    thumbnailPath: '/path/to/thumbnail',
+    faces: [],
+    isHidden: false,
+  }),
+};

+ 12 - 0
server/test/fixtures/search.stub.ts

@@ -0,0 +1,12 @@
+import { SearchResult } from '@app/domain';
+
+export const searchStub = {
+  emptyResults: Object.freeze<SearchResult<any>>({
+    total: 0,
+    count: 0,
+    page: 1,
+    items: [],
+    facets: [],
+    distances: [],
+  }),
+};

+ 282 - 0
server/test/fixtures/shared-link.stub.ts

@@ -0,0 +1,282 @@
+import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
+import { AssetType, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
+import { assetStub } from './asset.stub';
+import { authStub } from './auth.stub';
+import { userStub } from './user.stub';
+
+const today = new Date();
+const tomorrow = new Date();
+const yesterday = new Date();
+tomorrow.setDate(today.getDate() + 1);
+yesterday.setDate(yesterday.getDate() - 1);
+
+const sharedLinkBytes = Buffer.from(
+  '2c2b646895f84753bff43fb696ad124f3b0faf2a0bd547406f26fa4a76b5c71990092baa536275654b2ab7a191fb21a6d6cd',
+  'hex',
+);
+
+const assetInfo: ExifResponseDto = {
+  make: 'camera-make',
+  model: 'camera-model',
+  exifImageWidth: 500,
+  exifImageHeight: 500,
+  fileSizeInByte: 100,
+  orientation: 'orientation',
+  dateTimeOriginal: today,
+  modifyDate: today,
+  timeZone: 'America/Los_Angeles',
+  lensModel: 'fancy',
+  fNumber: 100,
+  focalLength: 100,
+  iso: 100,
+  exposureTime: '1/16',
+  latitude: 100,
+  longitude: 100,
+  city: 'city',
+  state: 'state',
+  country: 'country',
+  description: 'description',
+  projectionType: null,
+};
+
+const assetResponse: AssetResponseDto = {
+  id: 'id_1',
+  deviceAssetId: 'device_asset_id_1',
+  ownerId: 'user_id_1',
+  deviceId: 'device_id_1',
+  type: AssetType.VIDEO,
+  originalPath: 'fake_path/jpeg',
+  originalFileName: 'asset_1.jpeg',
+  resized: false,
+  thumbhash: null,
+  fileModifiedAt: today,
+  fileCreatedAt: today,
+  updatedAt: today,
+  isFavorite: false,
+  isArchived: false,
+  smartInfo: {
+    tags: [],
+    objects: ['a', 'b', 'c'],
+  },
+  duration: '0:00:00.00000',
+  exifInfo: assetInfo,
+  livePhotoVideoId: null,
+  tags: [],
+  people: [],
+  checksum: 'ZmlsZSBoYXNo',
+};
+
+const albumResponse: AlbumResponseDto = {
+  albumName: 'Test Album',
+  albumThumbnailAssetId: null,
+  createdAt: today,
+  updatedAt: today,
+  id: 'album-123',
+  ownerId: 'admin_id',
+  owner: mapUser(userStub.admin),
+  sharedUsers: [],
+  shared: false,
+  assets: [],
+  assetCount: 1,
+};
+
+export const sharedLinkStub = {
+  individual: Object.freeze({
+    id: '123',
+    userId: authStub.admin.id,
+    user: userStub.admin,
+    key: sharedLinkBytes,
+    type: SharedLinkType.INDIVIDUAL,
+    createdAt: today,
+    expiresAt: tomorrow,
+    allowUpload: true,
+    allowDownload: true,
+    showExif: true,
+    album: undefined,
+    description: null,
+    assets: [assetStub.image],
+  } as SharedLinkEntity),
+  valid: Object.freeze({
+    id: '123',
+    userId: authStub.admin.id,
+    user: userStub.admin,
+    key: sharedLinkBytes,
+    type: SharedLinkType.ALBUM,
+    createdAt: today,
+    expiresAt: tomorrow,
+    allowUpload: true,
+    allowDownload: true,
+    showExif: true,
+    album: undefined,
+    albumId: null,
+    description: null,
+    assets: [],
+  } as SharedLinkEntity),
+  expired: Object.freeze({
+    id: '123',
+    userId: authStub.admin.id,
+    user: userStub.admin,
+    key: sharedLinkBytes,
+    type: SharedLinkType.ALBUM,
+    createdAt: today,
+    expiresAt: yesterday,
+    allowUpload: true,
+    allowDownload: true,
+    showExif: true,
+    description: null,
+    albumId: null,
+    assets: [],
+  } as SharedLinkEntity),
+  readonlyNoExif: Object.freeze<SharedLinkEntity>({
+    id: '123',
+    userId: authStub.admin.id,
+    user: userStub.admin,
+    key: sharedLinkBytes,
+    type: SharedLinkType.ALBUM,
+    createdAt: today,
+    expiresAt: tomorrow,
+    allowUpload: false,
+    allowDownload: false,
+    showExif: false,
+    description: null,
+    assets: [],
+    albumId: 'album-123',
+    album: {
+      id: 'album-123',
+      ownerId: authStub.admin.id,
+      owner: userStub.admin,
+      albumName: 'Test Album',
+      createdAt: today,
+      updatedAt: today,
+      albumThumbnailAsset: null,
+      albumThumbnailAssetId: null,
+      sharedUsers: [],
+      sharedLinks: [],
+      assets: [
+        {
+          id: 'id_1',
+          owner: userStub.user1,
+          ownerId: 'user_id_1',
+          deviceAssetId: 'device_asset_id_1',
+          deviceId: 'device_id_1',
+          type: AssetType.VIDEO,
+          originalPath: 'fake_path/jpeg',
+          resizePath: '',
+          checksum: Buffer.from('file hash', 'utf8'),
+          fileModifiedAt: today,
+          fileCreatedAt: today,
+          createdAt: today,
+          updatedAt: today,
+          isFavorite: false,
+          isArchived: false,
+          isReadOnly: false,
+          smartInfo: {
+            assetId: 'id_1',
+            tags: [],
+            objects: ['a', 'b', 'c'],
+            asset: null as any,
+            clipEmbedding: [0.12, 0.13, 0.14],
+          },
+          webpPath: '',
+          thumbhash: null,
+          encodedVideoPath: '',
+          duration: null,
+          isVisible: true,
+          livePhotoVideo: null,
+          livePhotoVideoId: null,
+          originalFileName: 'asset_1.jpeg',
+          exifInfo: {
+            projectionType: null,
+            livePhotoCID: null,
+            assetId: 'id_1',
+            description: 'description',
+            exifImageWidth: 500,
+            exifImageHeight: 500,
+            fileSizeInByte: 100,
+            orientation: 'orientation',
+            dateTimeOriginal: today,
+            modifyDate: today,
+            timeZone: 'America/Los_Angeles',
+            latitude: 100,
+            longitude: 100,
+            city: 'city',
+            state: 'state',
+            country: 'country',
+            make: 'camera-make',
+            model: 'camera-model',
+            lensModel: 'fancy',
+            fNumber: 100,
+            focalLength: 100,
+            iso: 100,
+            exposureTime: '1/16',
+            fps: 100,
+            asset: null as any,
+            exifTextSearchableColumn: '',
+          },
+          tags: [],
+          sharedLinks: [],
+          faces: [],
+          sidecarPath: null,
+        },
+      ],
+    },
+  }),
+};
+
+export const sharedLinkResponseStub = {
+  valid: Object.freeze<SharedLinkResponseDto>({
+    allowDownload: true,
+    allowUpload: true,
+    assets: [],
+    createdAt: today,
+    description: null,
+    expiresAt: tomorrow,
+    id: '123',
+    key: sharedLinkBytes.toString('base64url'),
+    showExif: true,
+    type: SharedLinkType.ALBUM,
+    userId: 'admin_id',
+  }),
+  expired: Object.freeze<SharedLinkResponseDto>({
+    album: undefined,
+    allowDownload: true,
+    allowUpload: true,
+    assets: [],
+    createdAt: today,
+    description: null,
+    expiresAt: yesterday,
+    id: '123',
+    key: sharedLinkBytes.toString('base64url'),
+    showExif: true,
+    type: SharedLinkType.ALBUM,
+    userId: 'admin_id',
+  }),
+  readonly: Object.freeze<SharedLinkResponseDto>({
+    id: '123',
+    userId: 'admin_id',
+    key: sharedLinkBytes.toString('base64url'),
+    type: SharedLinkType.ALBUM,
+    createdAt: today,
+    expiresAt: tomorrow,
+    description: null,
+    allowUpload: false,
+    allowDownload: false,
+    showExif: true,
+    album: albumResponse,
+    assets: [assetResponse],
+  }),
+  readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
+    id: '123',
+    userId: 'admin_id',
+    key: sharedLinkBytes.toString('base64url'),
+    type: SharedLinkType.ALBUM,
+    createdAt: today,
+    expiresAt: tomorrow,
+    description: null,
+    allowUpload: false,
+    allowDownload: false,
+    showExif: false,
+    album: albumResponse,
+    assets: [{ ...assetResponse, exifInfo: undefined }],
+  }),
+};

+ 25 - 0
server/test/fixtures/system-config.stub.ts

@@ -0,0 +1,25 @@
+import { SystemConfigEntity, SystemConfigKey } from '@app/infra/entities';
+
+export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
+  defaults: [],
+  enabled: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
+  disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }],
+  noAutoRegister: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
+  override: [
+    { key: SystemConfigKey.OAUTH_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
+    { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true },
+    { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
+    { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
+  ],
+};

+ 24 - 0
server/test/fixtures/tag.stub.ts

@@ -0,0 +1,24 @@
+import { TagResponseDto } from '@app/domain';
+import { TagEntity, TagType } from '@app/infra/entities';
+import { userStub } from './user.stub';
+
+export const tagStub = {
+  tag1: Object.freeze<TagEntity>({
+    id: 'tag-1',
+    name: 'Tag1',
+    type: TagType.CUSTOM,
+    userId: userStub.admin.id,
+    user: userStub.admin,
+    renameTagId: null,
+    assets: [],
+  }),
+};
+
+export const tagResponseStub = {
+  tag1: Object.freeze<TagResponseDto>({
+    id: 'tag-1',
+    name: 'Tag1',
+    type: 'CUSTOM',
+    userId: 'admin_id',
+  }),
+};

+ 25 - 0
server/test/fixtures/user-token.stub.ts

@@ -0,0 +1,25 @@
+import { UserTokenEntity } from '@app/infra/entities';
+import { userStub } from './user.stub';
+
+export const userTokenStub = {
+  userToken: Object.freeze<UserTokenEntity>({
+    id: 'token-id',
+    token: 'auth_token',
+    userId: userStub.user1.id,
+    user: userStub.user1,
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date(),
+    deviceType: '',
+    deviceOS: '',
+  }),
+  inactiveToken: Object.freeze<UserTokenEntity>({
+    id: 'not_active',
+    token: 'auth_token',
+    userId: userStub.user1.id,
+    user: userStub.user1,
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    deviceType: 'Mobile',
+    deviceOS: 'Android',
+  }),
+};

+ 69 - 0
server/test/fixtures/user.stub.ts

@@ -0,0 +1,69 @@
+import { UserEntity } from '@app/infra/entities';
+import { authStub } from './auth.stub';
+
+export const userStub = {
+  admin: Object.freeze<UserEntity>({
+    ...authStub.admin,
+    password: 'admin_password',
+    firstName: 'admin_first_name',
+    lastName: 'admin_last_name',
+    storageLabel: 'admin',
+    externalPath: null,
+    oauthId: '',
+    shouldChangePassword: false,
+    profileImagePath: '',
+    createdAt: new Date('2021-01-01'),
+    deletedAt: null,
+    updatedAt: new Date('2021-01-01'),
+    tags: [],
+    assets: [],
+  }),
+  user1: Object.freeze<UserEntity>({
+    ...authStub.user1,
+    password: 'immich_password',
+    firstName: 'immich_first_name',
+    lastName: 'immich_last_name',
+    storageLabel: null,
+    externalPath: null,
+    oauthId: '',
+    shouldChangePassword: false,
+    profileImagePath: '',
+    createdAt: new Date('2021-01-01'),
+    deletedAt: null,
+    updatedAt: new Date('2021-01-01'),
+    tags: [],
+    assets: [],
+  }),
+  user2: Object.freeze<UserEntity>({
+    ...authStub.user2,
+    password: 'immich_password',
+    firstName: 'immich_first_name',
+    lastName: 'immich_last_name',
+    storageLabel: null,
+    externalPath: null,
+    oauthId: '',
+    shouldChangePassword: false,
+    profileImagePath: '',
+    createdAt: new Date('2021-01-01'),
+    deletedAt: null,
+    updatedAt: new Date('2021-01-01'),
+    tags: [],
+    assets: [],
+  }),
+  storageLabel: Object.freeze<UserEntity>({
+    ...authStub.user1,
+    password: 'immich_password',
+    firstName: 'immich_first_name',
+    lastName: 'immich_last_name',
+    storageLabel: 'label-1',
+    externalPath: null,
+    oauthId: '',
+    shouldChangePassword: false,
+    profileImagePath: '',
+    createdAt: new Date('2021-01-01'),
+    deletedAt: null,
+    updatedAt: new Date('2021-01-01'),
+    tags: [],
+    assets: [],
+  }),
+};