浏览代码

chore(server): Improve test coverage! (#3889)

* tests for person service

* tests for auth service

* tests for access core

* improve tests for album service

* fix missing brackets and remove comments

* tests for asset service

* tests for face recognition

* tests for job service

* feedback

* tests for search service (broken)

* fix: disabled search test

* tests for smart-info service

* tests for storage template service

* tests for user service

* fix formatting of untouched files LOL

* attempt to fix formatting

* streamline api utils, add asset api for uploading files

* test upload of assets

* fix formatting

* move test-utils to correct folder

* test add assets to album

* use random bytes instead of test image

* (e2e) test albums with assets

* (e2e) complete tests for album endpoints

* (e2e) tests for asset endpoint

* fix: asset upload/import dto validation

* (e2e) tests for statistics asset endpoint

* fix wrong describe text

* (e2e) tests for people with faces

* (e2e) clean up person tests

* (e2e) tests for partner sharing endpoints

* (e2e) tests for link sharing

* (e2e) tests for the asset time bucket endpoint

* fix minor issues

* remove access.core.spec.ts

* chore: wording

* chore: organize test api files

* chore: fix test describe

* implement feedback

* fix race condition in album tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Daniel Dietzler 1 年之前
父节点
当前提交
7173af60e4
共有 32 个文件被更改,包括 1635 次插入291 次删除
  1. 15 29
      server/src/domain/album/album.service.spec.ts
  2. 4 3
      server/src/domain/album/album.service.ts
  3. 68 1
      server/src/domain/asset/asset.service.spec.ts
  4. 9 0
      server/src/domain/auth/auth.service.spec.ts
  5. 25 1
      server/src/domain/facial-recognition/facial-recognition.service.spec.ts
  6. 16 1
      server/src/domain/job/job.service.spec.ts
  7. 9 0
      server/src/domain/person/person.service.spec.ts
  8. 83 14
      server/src/domain/search/search.service.spec.ts
  9. 38 2
      server/src/domain/smart-info/smart-info.service.spec.ts
  10. 35 0
      server/src/domain/storage-template/storage-template.service.spec.ts
  11. 1 2
      server/src/domain/storage-template/storage-template.service.ts
  12. 84 9
      server/src/domain/user/user.service.spec.ts
  13. 2 2
      server/src/immich/api-v1/asset/asset.controller.ts
  14. 14 3
      server/src/immich/api-v1/asset/dto/create-asset.dto.ts
  15. 18 0
      server/test/api/album-api.ts
  16. 34 0
      server/test/api/asset-api.ts
  17. 46 0
      server/test/api/auth-api.ts
  18. 13 0
      server/test/api/index.ts
  19. 13 0
      server/test/api/shared-link-api.ts
  20. 47 0
      server/test/api/user-api.ts
  21. 24 0
      server/test/db/index.ts
  22. 203 20
      server/test/e2e/album.e2e-spec.ts
  23. 310 12
      server/test/e2e/asset.e2e-spec.ts
  24. 14 13
      server/test/e2e/auth.e2e-spec.ts
  25. 4 3
      server/test/e2e/oauth.e2e-spec.ts
  26. 146 0
      server/test/e2e/partner.e2e-spec.ts
  27. 97 12
      server/test/e2e/person.e2e-spec.ts
  28. 6 5
      server/test/e2e/server-info.e2e-spec.ts
  29. 241 0
      server/test/e2e/shared-link.e2e-spec.ts
  30. 5 4
      server/test/e2e/user.e2e-spec.ts
  31. 11 0
      server/test/fixtures/search.stub.ts
  32. 0 155
      server/test/test-utils.ts

+ 15 - 29
server/src/domain/album/album.service.spec.ts

@@ -153,44 +153,30 @@ describe(AlbumService.name, () => {
   describe('create', () => {
     it('creates album', async () => {
       albumMock.create.mockResolvedValue(albumStub.empty);
+      userMock.get.mockResolvedValue(userStub.user1);
 
-      await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({
+      await sut.create(authStub.admin, {
         albumName: 'Empty album',
+        sharedWithUserIds: ['user-id'],
         description: '',
-        albumThumbnailAssetId: null,
-        assetCount: 0,
-        assets: [],
-        createdAt: expect.anything(),
-        id: 'album-1',
-        owner: {
-          email: 'admin@test.com',
-          firstName: 'admin_first_name',
-          id: 'admin_id',
-          isAdmin: true,
-          lastName: 'admin_last_name',
-          oauthId: '',
-          profileImagePath: '',
-          shouldChangePassword: false,
-          storageLabel: 'admin',
-          createdAt: new Date('2021-01-01'),
-          deletedAt: null,
-          updatedAt: new Date('2021-01-01'),
-          externalPath: null,
-          memoriesEnabled: true,
-        },
-        ownerId: 'admin_id',
-        shared: false,
-        sharedUsers: [],
-        startDate: undefined,
-        endDate: undefined,
-        hasSharedLink: false,
-        updatedAt: expect.anything(),
+        assetIds: ['123'],
       });
 
       expect(jobMock.queue).toHaveBeenCalledWith({
         name: JobName.SEARCH_INDEX_ALBUM,
         data: { ids: [albumStub.empty.id] },
       });
+
+      expect(albumMock.create).toHaveBeenCalledWith({
+        ownerId: authStub.admin.id,
+        albumName: albumStub.empty.albumName,
+        description: albumStub.empty.description,
+        sharedUsers: [{ id: 'user-id' }],
+        assets: [{ id: '123' }],
+        albumThumbnailAssetId: '123',
+      });
+
+      expect(userMock.get).toHaveBeenCalledWith('user-id');
     });
 
     it('should require valid userIds', async () => {

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

@@ -136,9 +136,6 @@ export class AlbumService {
     await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
 
     const album = await this.findOrFail(id, { withAssets: false });
-    if (!album) {
-      throw new BadRequestException('Album not found');
-    }
 
     await this.albumRepository.delete(album);
     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
@@ -228,6 +225,10 @@ export class AlbumService {
     const album = await this.findOrFail(id, { withAssets: false });
 
     for (const userId of dto.sharedUserIds) {
+      if (album.ownerId === userId) {
+        throw new BadRequestException('Cannot be shared with owner');
+      }
+
       const exists = album.sharedUsers.find((user) => user.id === userId);
       if (exists) {
         throw new BadRequestException('User already added');

+ 68 - 1
server/src/domain/asset/asset.service.spec.ts

@@ -15,7 +15,7 @@ import { Readable } from 'stream';
 import { ICryptoRepository } from '../crypto';
 import { IJobRepository, JobName } from '../job';
 import { IStorageRepository } from '../storage';
-import { AssetStats, IAssetRepository } from './asset.repository';
+import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository';
 import { AssetService, UploadFieldName } from './asset.service';
 import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
 import { mapAsset } from './response-dto';
@@ -330,6 +330,73 @@ describe(AssetService.name, () => {
     });
   });
 
+  describe('getTimeBuckets', () => {
+    it("should return buckets if userId and albumId aren't set", async () => {
+      assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
+
+      await expect(
+        sut.getTimeBuckets(authStub.admin, {
+          size: TimeBucketSize.DAY,
+        }),
+      ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
+      expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userId: authStub.admin.id });
+    });
+  });
+
+  describe('getByTimeBucket', () => {
+    it('should return the assets for a album time bucket if user has album.read', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
+      assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]);
+
+      await expect(
+        sut.getByTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
+      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+
+      expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-id');
+      expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', {
+        size: TimeBucketSize.DAY,
+        timeBucket: 'bucket',
+        albumId: 'album-id',
+      });
+    });
+
+    it('should return the assets for a archive time bucket if user has archive.read', async () => {
+      assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]);
+
+      await expect(
+        sut.getByTimeBucket(authStub.admin, {
+          size: TimeBucketSize.DAY,
+          timeBucket: 'bucket',
+          isArchived: true,
+          userId: authStub.admin.id,
+        }),
+      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', {
+        size: TimeBucketSize.DAY,
+        timeBucket: 'bucket',
+        isArchived: true,
+        userId: authStub.admin.id,
+      });
+    });
+
+    it('should return the assets for a library time bucket if user has library.read', async () => {
+      assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]);
+
+      await expect(
+        sut.getByTimeBucket(authStub.admin, {
+          size: TimeBucketSize.DAY,
+          timeBucket: 'bucket',
+          userId: authStub.admin.id,
+        }),
+      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', {
+        size: TimeBucketSize.DAY,
+        timeBucket: 'bucket',
+        userId: authStub.admin.id,
+      });
+    });
+  });
+
   describe('downloadFile', () => {
     it('should require the asset.download permission', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);

+ 9 - 0
server/src/domain/auth/auth.service.spec.ts

@@ -214,6 +214,15 @@ describe('AuthService', () => {
 
       expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
     });
+
+    it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
+      const authUser = { id: '123' } as AuthUserDto;
+
+      await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
+        successful: true,
+        redirectUri: '/auth/login?autoLaunch=0',
+      });
+    });
   });
 
   describe('adminSignUp', () => {

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

@@ -1,4 +1,4 @@
-import { Colorspace } from '@app/infra/entities';
+import { Colorspace, SystemConfigKey } from '@app/infra/entities';
 import {
   assetStub,
   faceStub,
@@ -137,6 +137,14 @@ describe(FacialRecognitionService.name, () => {
   });
 
   describe('handleQueueRecognizeFaces', () => {
+    it('should return if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true);
+      expect(jobMock.queue).not.toHaveBeenCalled();
+      expect(configMock.load).toHaveBeenCalled();
+    });
+
     it('should queue missing assets', async () => {
       assetMock.getWithout.mockResolvedValue({
         items: [assetStub.image],
@@ -170,6 +178,14 @@ describe(FacialRecognitionService.name, () => {
   });
 
   describe('handleRecognizeFaces', () => {
+    it('should return if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await expect(sut.handleRecognizeFaces({ id: 'foo' })).resolves.toBe(true);
+      expect(assetMock.getByIds).not.toHaveBeenCalled();
+      expect(configMock.load).toHaveBeenCalled();
+    });
+
     it('should skip when no resize path', async () => {
       assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
       await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id });
@@ -260,6 +276,14 @@ describe(FacialRecognitionService.name, () => {
   });
 
   describe('handleGenerateFaceThumbnail', () => {
+    it('should return if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await expect(sut.handleGenerateFaceThumbnail(face.middle)).resolves.toBe(true);
+      expect(assetMock.getByIds).not.toHaveBeenCalled();
+      expect(configMock.load).toHaveBeenCalled();
+    });
+
     it('should skip an asset not found', async () => {
       assetMock.getByIds.mockResolvedValue([]);
 

+ 16 - 1
server/src/domain/job/job.service.spec.ts

@@ -288,6 +288,17 @@ describe(JobService.name, () => {
           JobName.VIDEO_CONVERSION,
         ],
       },
+      {
+        item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-live-image', source: 'upload' } },
+        jobs: [
+          JobName.CLASSIFY_IMAGE,
+          JobName.GENERATE_WEBP_THUMBNAIL,
+          JobName.RECOGNIZE_FACES,
+          JobName.GENERATE_THUMBHASH_THUMBNAIL,
+          JobName.ENCODE_CLIP,
+          JobName.VIDEO_CONVERSION,
+        ],
+      },
       {
         item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },
         jobs: [JobName.SEARCH_INDEX_ASSET],
@@ -305,7 +316,11 @@ 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([assetStub.livePhotoMotionAsset]);
+          if (item.data.id === 'asset-live-image') {
+            assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
+          } else {
+            assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
+          }
         } else {
           assetMock.getByIds.mockResolvedValue([]);
         }

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

@@ -215,6 +215,15 @@ describe(PersonService.name, () => {
         },
       });
     });
+
+    it('should throw an error when the face feature assetId is invalid', async () => {
+      personMock.getById.mockResolvedValue(personStub.withName);
+
+      await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
+        BadRequestException,
+      );
+      expect(personMock.update).not.toHaveBeenCalled();
+    });
   });
 
   describe('updateAll', () => {

+ 83 - 14
server/src/domain/search/search.service.spec.ts

@@ -1,3 +1,4 @@
+import { BadRequestException } from '@nestjs/common';
 import {
   albumStub,
   assetStub,
@@ -15,12 +16,13 @@ import {
 } from '@test';
 import { plainToInstance } from 'class-transformer';
 import { IAlbumRepository } from '../album/album.repository';
+import { mapAsset } from '../asset';
 import { IAssetRepository } from '../asset/asset.repository';
 import { IFaceRepository } from '../facial-recognition';
-import { ISystemConfigRepository } from '../index';
 import { JobName } from '../job';
 import { IJobRepository } from '../job/job.repository';
 import { IMachineLearningRepository } from '../smart-info';
+import { ISystemConfigRepository } from '../system-config';
 import { SearchDto } from './dto';
 import { ISearchRepository } from './search.repository';
 import { SearchService } from './search.service';
@@ -50,9 +52,17 @@ describe(SearchService.name, () => {
 
     searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false });
 
+    delete process.env.TYPESENSE_ENABLED;
     await sut.init();
   });
 
+  const disableSearch = () => {
+    searchMock.setup.mockClear();
+    searchMock.checkMigrationStatus.mockClear();
+    jobMock.queue.mockClear();
+    process.env.TYPESENSE_ENABLED = 'false';
+  };
+
   afterEach(() => {
     sut.teardown();
   });
@@ -84,15 +94,14 @@ describe(SearchService.name, () => {
   });
 
   describe(`init`, () => {
-    // it('should skip when search is disabled', async () => {
-    //   await sut.init();
-
-    //   expect(searchMock.setup).not.toHaveBeenCalled();
-    //   expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled();
-    //   expect(jobMock.queue).not.toHaveBeenCalled();
+    it('should skip when search is disabled', async () => {
+      disableSearch();
+      await sut.init();
 
-    //   sut.teardown();
-    // });
+      expect(searchMock.setup).not.toHaveBeenCalled();
+      expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled();
+      expect(jobMock.queue).not.toHaveBeenCalled();
+    });
 
     it('should skip schema migration if not needed', async () => {
       await sut.init();
@@ -114,6 +123,29 @@ describe(SearchService.name, () => {
     });
   });
 
+  describe('getExploreData', () => {
+    it('should throw bad request exception if search is disabled', async () => {
+      disableSearch();
+      await expect(sut.getExploreData(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
+      expect(searchMock.explore).not.toHaveBeenCalled();
+    });
+
+    it('should return explore data if feature flag SEARCH is set', async () => {
+      searchMock.explore.mockResolvedValue([{ fieldName: 'name', items: [{ value: 'image', data: assetStub.image }] }]);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+
+      await expect(sut.getExploreData(authStub.admin)).resolves.toEqual([
+        {
+          fieldName: 'name',
+          items: [{ value: 'image', data: mapAsset(assetStub.image) }],
+        },
+      ]);
+
+      expect(searchMock.explore).toHaveBeenCalledWith(authStub.admin.id);
+      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
+    });
+  });
+
   describe('search', () => {
     // it('should throw an error is search is disabled', async () => {
     //   sut['enabled'] = false;
@@ -124,12 +156,40 @@ describe(SearchService.name, () => {
     //   expect(searchMock.searchAssets).not.toHaveBeenCalled();
     // });
 
-    it('should search assets and albums', async () => {
-      searchMock.searchAssets.mockResolvedValue(searchStub.emptyResults);
+    it('should search assets and albums using text search', async () => {
+      searchMock.searchAssets.mockResolvedValue(searchStub.withImage);
       searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults);
-      searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults);
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       await expect(sut.search(authStub.admin, {})).resolves.toEqual({
+        albums: {
+          total: 0,
+          count: 0,
+          page: 1,
+          items: [],
+          facets: [],
+          distances: [],
+        },
+        assets: {
+          total: 1,
+          count: 1,
+          page: 1,
+          items: [mapAsset(assetStub.image)],
+          facets: [],
+          distances: [],
+        },
+      });
+
+      // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id });
+      expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id });
+    });
+
+    it('should search assets and albums using vector search', async () => {
+      searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults);
+      searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults);
+      machineMock.encodeText.mockResolvedValue([123]);
+
+      await expect(sut.search(authStub.admin, { clip: true, query: 'foo' })).resolves.toEqual({
         albums: {
           total: 0,
           count: 0,
@@ -148,8 +208,17 @@ describe(SearchService.name, () => {
         },
       });
 
-      // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id });
-      expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id });
+      expect(machineMock.encodeText).toHaveBeenCalledWith(expect.any(String), { text: 'foo' }, expect.any(Object));
+      expect(searchMock.vectorSearch).toHaveBeenCalledWith([123], {
+        userId: authStub.admin.id,
+        clip: true,
+        query: 'foo',
+      });
+      expect(searchMock.searchAlbums).toHaveBeenCalledWith('foo', {
+        userId: authStub.admin.id,
+        clip: true,
+        query: 'foo',
+      });
     });
   });
 

+ 38 - 2
server/src/domain/smart-info/smart-info.service.spec.ts

@@ -1,4 +1,4 @@
-import { AssetEntity } from '@app/infra/entities';
+import { AssetEntity, SystemConfigKey } from '@app/infra/entities';
 import {
   assetStub,
   newAssetRepositoryMock,
@@ -43,6 +43,15 @@ describe(SmartInfoService.name, () => {
   });
 
   describe('handleQueueObjectTagging', () => {
+    it('should do nothing if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await sut.handleQueueObjectTagging({});
+
+      expect(assetMock.getAll).not.toHaveBeenCalled();
+      expect(assetMock.getWithout).not.toHaveBeenCalled();
+    });
+
     it('should queue the assets without tags', async () => {
       assetMock.getWithout.mockResolvedValue({
         items: [assetStub.image],
@@ -68,7 +77,16 @@ describe(SmartInfoService.name, () => {
     });
   });
 
-  describe('handleTagImage', () => {
+  describe('handleClassifyImage', () => {
+    it('should do nothing if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await sut.handleClassifyImage({ id: '123' });
+
+      expect(machineMock.classifyImage).not.toHaveBeenCalled();
+      expect(assetMock.getByIds).not.toHaveBeenCalled();
+    });
+
     it('should skip assets without a resize path', async () => {
       const asset = { resizePath: '' } as AssetEntity;
       assetMock.getByIds.mockResolvedValue([asset]);
@@ -108,6 +126,15 @@ describe(SmartInfoService.name, () => {
   });
 
   describe('handleQueueEncodeClip', () => {
+    it('should do nothing if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await sut.handleQueueEncodeClip({});
+
+      expect(assetMock.getAll).not.toHaveBeenCalled();
+      expect(assetMock.getWithout).not.toHaveBeenCalled();
+    });
+
     it('should queue the assets without clip embeddings', async () => {
       assetMock.getWithout.mockResolvedValue({
         items: [assetStub.image],
@@ -134,6 +161,15 @@ describe(SmartInfoService.name, () => {
   });
 
   describe('handleEncodeClip', () => {
+    it('should do nothing if machine learning is disabled', async () => {
+      configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
+
+      await sut.handleEncodeClip({ id: '123' });
+
+      expect(assetMock.getByIds).not.toHaveBeenCalled();
+      expect(machineMock.encodeImage).not.toHaveBeenCalled();
+    });
+
     it('should skip assets without a resize path', async () => {
       const asset = { resizePath: '' } as AssetEntity;
       assetMock.getByIds.mockResolvedValue([asset]);

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

@@ -34,6 +34,41 @@ describe(StorageTemplateService.name, () => {
     sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
   });
 
+  describe('handleMigrationSingle', () => {
+    it('should migrate single moving picture', async () => {
+      userMock.get.mockResolvedValue(userStub.user1);
+      const path = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}.jpg`;
+      const newPath = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}+1.jpg`;
+
+      when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoStillAsset.id)).mockResolvedValue(true);
+      when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoStillAsset.id)).mockResolvedValue(false);
+
+      when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(true);
+      when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(false);
+
+      when(assetMock.save)
+        .calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newPath(assetStub.livePhotoStillAsset.id) })
+        .mockResolvedValue(assetStub.livePhotoStillAsset);
+
+      when(assetMock.save)
+        .calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newPath(assetStub.livePhotoMotionAsset.id) })
+        .mockResolvedValue(assetStub.livePhotoMotionAsset);
+
+      when(assetMock.getByIds)
+        .calledWith([assetStub.livePhotoStillAsset.id])
+        .mockResolvedValue([assetStub.livePhotoStillAsset]);
+
+      when(assetMock.getByIds)
+        .calledWith([assetStub.livePhotoMotionAsset.id])
+        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
+
+      await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
+
+      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
+      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
+    });
+  });
+
   describe('handle template migration', () => {
     it('should handle no assets', async () => {
       assetMock.getAll.mockResolvedValue({

+ 1 - 2
server/src/domain/storage-template/storage-template.service.ts

@@ -89,7 +89,6 @@ export class StorageTemplateService {
     return true;
   }
 
-  // TODO: use asset core (once in domain)
   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
     if (asset.isReadOnly) {
       this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`);
@@ -121,7 +120,7 @@ export class StorageTemplateService {
             error?.stack,
           );
 
-          // Either sidecar move failed or the save failed. Eithr way, move media back
+          // Either sidecar move failed or the save failed. Either way, move media back
           await this.storageRepository.moveFile(destination, source);
 
           if (asset.sidecarPath && sidecarDestination && sidecarMoved) {

+ 84 - 9
server/src/domain/user/user.service.spec.ts

@@ -1,5 +1,10 @@
 import { UserEntity } from '@app/infra/entities';
-import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
+import {
+  BadRequestException,
+  ForbiddenException,
+  InternalServerErrorException,
+  NotFoundException,
+} from '@nestjs/common';
 import {
   newAlbumRepositoryMock,
   newAssetRepositoryMock,
@@ -7,6 +12,7 @@ import {
   newJobRepositoryMock,
   newStorageRepositoryMock,
   newUserRepositoryMock,
+  userStub,
 } from '@test';
 import { when } from 'jest-when';
 import { IAlbumRepository } from '../album';
@@ -16,7 +22,7 @@ import { ICryptoRepository } from '../crypto';
 import { IJobRepository, JobName } from '../job';
 import { IStorageRepository } from '../storage';
 import { UpdateUserDto } from './dto/update-user.dto';
-import { UserResponseDto } from './response-dto';
+import { UserResponseDto, mapUser } from './response-dto';
 import { IUserRepository } from './user.repository';
 import { UserService } from './user.service';
 
@@ -216,6 +222,13 @@ describe(UserService.name, () => {
       expect(userMock.getList).toHaveBeenCalled();
       expect(response).toEqual({ userCount: 1 });
     });
+
+    it('should get the user count of all admin users', async () => {
+      userMock.getList.mockResolvedValue([adminUser, immichUser]);
+
+      await expect(sut.getCount({ admin: true })).resolves.toEqual({ userCount: 1 });
+      expect(userMock.getList).toHaveBeenCalled();
+    });
   });
 
   describe('update', () => {
@@ -223,12 +236,17 @@ describe(UserService.name, () => {
       const update: UpdateUserDto = {
         id: immichUser.id,
         shouldChangePassword: true,
+        email: 'immich@test.com',
+        storageLabel: 'storage_label',
       };
+      userMock.getByEmail.mockResolvedValue(null);
+      userMock.getByStorageLabel.mockResolvedValue(null);
+      userMock.update.mockResolvedValue({ ...updatedImmichUser, isAdmin: true, storageLabel: 'storage_label' });
 
-      when(userMock.update).calledWith(update.id, update).mockResolvedValueOnce(updatedImmichUser);
-
-      const updatedUser = await sut.update(immichUserAuth, update);
+      const updatedUser = await sut.update({ ...immichUserAuth, isAdmin: true }, update);
       expect(updatedUser.shouldChangePassword).toEqual(true);
+      expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
+      expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
     });
 
     it('should not set an empty string for storage label', async () => {
@@ -345,20 +363,37 @@ describe(UserService.name, () => {
   });
 
   describe('restore', () => {
+    it('should throw error if user could not be found', async () => {
+      userMock.get.mockResolvedValue(null);
+
+      await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException);
+      expect(userMock.restore).not.toHaveBeenCalled();
+    });
+
     it('should require an admin', async () => {
       when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser);
       await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException);
       expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true);
     });
 
-    it('should require the auth user be an admin', async () => {
-      await expect(sut.delete(immichUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException);
+    it('should restore an user', async () => {
+      userMock.get.mockResolvedValue(immichUser);
+      userMock.restore.mockResolvedValue(immichUser);
 
-      expect(userMock.delete).not.toHaveBeenCalled();
+      await expect(sut.restore(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser));
+      expect(userMock.get).toHaveBeenCalledWith(immichUser.id, true);
+      expect(userMock.restore).toHaveBeenCalledWith(immichUser);
     });
   });
 
   describe('delete', () => {
+    it('should throw error if user could not be found', async () => {
+      userMock.get.mockResolvedValue(null);
+
+      await expect(sut.delete(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException);
+      expect(userMock.delete).not.toHaveBeenCalled();
+    });
+
     it('cannot delete admin user', async () => {
       await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException);
     });
@@ -368,9 +403,18 @@ describe(UserService.name, () => {
 
       expect(userMock.delete).not.toHaveBeenCalled();
     });
+
+    it('should delete user', async () => {
+      userMock.get.mockResolvedValue(immichUser);
+      userMock.delete.mockResolvedValue(immichUser);
+
+      await expect(sut.delete(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser));
+      expect(userMock.get).toHaveBeenCalledWith(immichUser.id, undefined);
+      expect(userMock.delete).toHaveBeenCalledWith(immichUser);
+    });
   });
 
-  describe('update', () => {
+  describe('create', () => {
     it('should not create a user if there is no local admin account', async () => {
       when(userMock.getAdmin).calledWith().mockResolvedValueOnce(null);
 
@@ -383,6 +427,30 @@ describe(UserService.name, () => {
         }),
       ).rejects.toBeInstanceOf(BadRequestException);
     });
+
+    it('should create user', async () => {
+      userMock.getAdmin.mockResolvedValue(userStub.admin);
+      userMock.create.mockResolvedValue(userStub.user1);
+
+      await expect(
+        sut.create({
+          email: userStub.user1.email,
+          firstName: userStub.user1.firstName,
+          lastName: userStub.user1.lastName,
+          password: 'password',
+          storageLabel: 'label',
+        }),
+      ).resolves.toEqual(mapUser(userStub.user1));
+
+      expect(userMock.getAdmin).toBeCalled();
+      expect(userMock.create).toBeCalledWith({
+        email: userStub.user1.email,
+        firstName: userStub.user1.firstName,
+        lastName: userStub.user1.lastName,
+        storageLabel: 'label',
+        password: expect.anything(),
+      });
+    });
   });
 
   describe('createProfileImage', () => {
@@ -394,6 +462,13 @@ describe(UserService.name, () => {
 
       expect(userMock.update).toHaveBeenCalledWith(adminUserAuth.id, { profileImagePath: file.path });
     });
+
+    it('should throw an error if the user profile could not be updated with the new image', async () => {
+      const file = { path: '/profile/path' } as Express.Multer.File;
+      userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
+
+      await expect(sut.createProfileImage(adminUserAuth, file)).rejects.toThrowError(InternalServerErrorException);
+    });
   });
 
   describe('getUserProfileImage', () => {

+ 2 - 2
server/src/immich/api-v1/asset/asset.controller.ts

@@ -63,7 +63,7 @@ export class AssetController {
   async uploadFile(
     @AuthUser() authUser: AuthUserDto,
     @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
-    @Body(new ValidationPipe()) dto: CreateAssetDto,
+    @Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto,
     @Response({ passthrough: true }) res: Res,
   ): Promise<AssetFileUploadResponseDto> {
     const file = mapToUploadFile(files.assetData[0]);
@@ -90,7 +90,7 @@ export class AssetController {
   @Post('import')
   async importFile(
     @AuthUser() authUser: AuthUserDto,
-    @Body(new ValidationPipe()) dto: ImportAssetDto,
+    @Body(new ValidationPipe({ transform: true })) dto: ImportAssetDto,
     @Response({ passthrough: true }) res: Res,
   ): Promise<AssetFileUploadResponseDto> {
     const responseDto = await this.assetService.importFile(authUser, dto);

+ 14 - 3
server/src/immich/api-v1/asset/dto/create-asset.dto.ts

@@ -1,33 +1,43 @@
 import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain';
 import { ApiProperty } from '@nestjs/swagger';
-import { Transform } from 'class-transformer';
-import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator';
 
 export class CreateAssetBase {
   @IsNotEmpty()
+  @IsString()
   deviceAssetId!: string;
 
   @IsNotEmpty()
+  @IsString()
   deviceId!: string;
 
   @IsNotEmpty()
+  @IsDate()
+  @Type(() => Date)
   fileCreatedAt!: Date;
 
   @IsNotEmpty()
+  @IsDate()
+  @Type(() => Date)
   fileModifiedAt!: Date;
 
-  @IsNotEmpty()
+  @IsBoolean()
+  @Transform(toBoolean)
   isFavorite!: boolean;
 
   @Optional()
   @IsBoolean()
+  @Transform(toBoolean)
   isArchived?: boolean;
 
   @Optional()
   @IsBoolean()
+  @Transform(toBoolean)
   isVisible?: boolean;
 
   @Optional()
+  @IsString()
   duration?: string;
 }
 
@@ -51,6 +61,7 @@ export class CreateAssetDto extends CreateAssetBase {
 
 export class ImportAssetDto extends CreateAssetBase {
   @Optional()
+  @IsBoolean()
   @Transform(toBoolean)
   isReadOnly?: boolean = true;
 

+ 18 - 0
server/test/api/album-api.ts

@@ -0,0 +1,18 @@
+import { AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain';
+import request from 'supertest';
+
+export const albumApi = {
+  create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
+    const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
+    expect(res.status).toEqual(201);
+    return res.body as AlbumResponseDto;
+  },
+  addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => {
+    const res = await request(server)
+      .put(`/album/${id}/assets`)
+      .set('Authorization', `Bearer ${accessToken}`)
+      .send(dto);
+    expect(res.status).toEqual(200);
+    return res.body as BulkIdResponseDto[];
+  },
+};

+ 34 - 0
server/test/api/asset-api.ts

@@ -0,0 +1,34 @@
+import { AssetResponseDto } from '@app/domain';
+import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto';
+import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
+import { randomBytes } from 'crypto';
+import request from 'supertest';
+
+type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
+
+export const assetApi = {
+  get: async (server: any, accessToken: string, id: string) => {
+    const { body, status } = await request(server)
+      .get(`/asset/assetById/${id}`)
+      .set('Authorization', `Bearer ${accessToken}`);
+    expect(status).toBe(200);
+    return body as AssetResponseDto;
+  },
+  upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
+    const { content, isFavorite = false, isArchived = false } = dto;
+    const { body, status } = await request(server)
+      .post('/asset/upload')
+      .set('Authorization', `Bearer ${accessToken}`)
+      .field('deviceAssetId', id)
+      .field('deviceId', 'TEST')
+      .field('fileCreatedAt', new Date().toISOString())
+      .field('fileModifiedAt', new Date().toISOString())
+      .field('isFavorite', isFavorite)
+      .field('isArchived', isArchived)
+      .field('duration', '0:00:00.000000')
+      .attach('assetData', content || randomBytes(32), 'example.jpg');
+
+    expect(status).toBe(201);
+    return body as AssetFileUploadResponseDto;
+  },
+};

+ 46 - 0
server/test/api/auth-api.ts

@@ -0,0 +1,46 @@
+import { AdminSignupResponseDto, AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto } from '@app/domain';
+import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from '@test';
+import request from 'supertest';
+
+export const authApi = {
+  adminSignUp: async (server: any) => {
+    const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
+
+    expect(status).toBe(201);
+    expect(body).toEqual(signupResponseStub);
+
+    return body as AdminSignupResponseDto;
+  },
+  adminLogin: async (server: any) => {
+    const { status, body } = await request(server).post('/auth/login').send(loginStub.admin);
+
+    expect(body).toEqual(loginResponseStub.admin.response);
+    expect(body).toMatchObject({ accessToken: expect.any(String) });
+    expect(status).toBe(201);
+
+    return body as LoginResponseDto;
+  },
+  login: async (server: any, dto: LoginCredentialDto) => {
+    const { status, body } = await request(server).post('/auth/login').send(dto);
+
+    expect(status).toEqual(201);
+    expect(body).toMatchObject({ accessToken: expect.any(String) });
+
+    return body as LoginResponseDto;
+  },
+  getAuthDevices: async (server: any, accessToken: string) => {
+    const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
+
+    expect(body).toEqual(expect.any(Array));
+    expect(status).toBe(200);
+
+    return body as AuthDeviceResponseDto[];
+  },
+  validateToken: async (server: any, accessToken: string) => {
+    const { status, body } = await request(server)
+      .post('/auth/validateToken')
+      .set('Authorization', `Bearer ${accessToken}`);
+    expect(body).toEqual({ authStatus: true });
+    expect(status).toBe(200);
+  },
+};

+ 13 - 0
server/test/api/index.ts

@@ -0,0 +1,13 @@
+import { albumApi } from './album-api';
+import { assetApi } from './asset-api';
+import { authApi } from './auth-api';
+import { sharedLinkApi } from './shared-link-api';
+import { userApi } from './user-api';
+
+export const api = {
+  authApi,
+  assetApi,
+  sharedLinkApi,
+  albumApi,
+  userApi,
+};

+ 13 - 0
server/test/api/shared-link-api.ts

@@ -0,0 +1,13 @@
+import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain';
+import request from 'supertest';
+
+export const sharedLinkApi = {
+  create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
+    const { status, body } = await request(server)
+      .post('/shared-link')
+      .set('Authorization', `Bearer ${accessToken}`)
+      .send(dto);
+    expect(status).toBe(201);
+    return body as SharedLinkResponseDto;
+  },
+};

+ 47 - 0
server/test/api/user-api.ts

@@ -0,0 +1,47 @@
+import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain';
+import request from 'supertest';
+
+export const userApi = {
+  create: async (server: any, accessToken: string, dto: CreateUserDto) => {
+    const { status, body } = await request(server)
+      .post('/user')
+      .set('Authorization', `Bearer ${accessToken}`)
+      .send(dto);
+
+    expect(status).toBe(201);
+    expect(body).toMatchObject({
+      id: expect.any(String),
+      createdAt: expect.any(String),
+      updatedAt: expect.any(String),
+      email: dto.email,
+    });
+
+    return body as UserResponseDto;
+  },
+  get: async (server: any, accessToken: string, id: string) => {
+    const { status, body } = await request(server)
+      .get(`/user/info/${id}`)
+      .set('Authorization', `Bearer ${accessToken}`);
+
+    expect(status).toBe(200);
+    expect(body).toMatchObject({ id });
+
+    return body as UserResponseDto;
+  },
+  update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
+    const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
+
+    expect(status).toBe(200);
+    expect(body).toMatchObject({ id: dto.id });
+
+    return body as UserResponseDto;
+  },
+  delete: async (server: any, accessToken: string, id: string) => {
+    const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
+
+    expect(status).toBe(200);
+    expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
+
+    return body as UserResponseDto;
+  },
+};

+ 24 - 0
server/test/db/index.ts

@@ -0,0 +1,24 @@
+import { dataSource } from '@app/infra';
+
+export const db = {
+  reset: async () => {
+    if (!dataSource.isInitialized) {
+      await dataSource.initialize();
+    }
+
+    await dataSource.transaction(async (em) => {
+      for (const entity of dataSource.entityMetadatas) {
+        if (entity.tableName === 'users') {
+          continue;
+        }
+        await em.query(`DELETE FROM ${entity.tableName} CASCADE;`);
+      }
+      await em.query(`DELETE FROM "users" CASCADE;`);
+    });
+  },
+  disconnect: async () => {
+    if (dataSource.isInitialized) {
+      await dataSource.destroy();
+    }
+  },
+};

+ 203 - 20
server/test/e2e/album.e2e-spec.ts

@@ -1,11 +1,13 @@
-import { LoginResponseDto } from '@app/domain';
+import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
 import { AlbumController, AppModule } from '@app/immich';
+import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
 import { SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub, uuidStub } from '@test/fixtures';
 import request from 'supertest';
-import { errorStub, uuidStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 const user1SharedUser = 'user1SharedUser';
 const user1SharedLink = 'user1SharedLink';
@@ -18,7 +20,10 @@ describe(`${AlbumController.name} (e2e)`, () => {
   let app: INestApplication;
   let server: any;
   let user1: LoginResponseDto;
+  let user1Asset: AssetFileUploadResponseDto;
+  let user1Albums: AlbumResponseDto[];
   let user2: LoginResponseDto;
+  let user2Albums: AlbumResponseDto[];
 
   beforeAll(async () => {
     const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -31,8 +36,8 @@ describe(`${AlbumController.name} (e2e)`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    const admin = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    const admin = await api.authApi.adminLogin(server);
 
     await api.userApi.create(server, admin.accessToken, {
       email: 'user1@immich.app',
@@ -40,7 +45,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
       firstName: 'User 1',
       lastName: 'Test',
     });
-    user1 = await api.login(server, { email: 'user1@immich.app', password: 'Password123' });
+    user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' });
 
     await api.userApi.create(server, admin.accessToken, {
       email: 'user2@immich.app',
@@ -48,15 +53,17 @@ describe(`${AlbumController.name} (e2e)`, () => {
       firstName: 'User 2',
       lastName: 'Test',
     });
-    user2 = await api.login(server, { email: 'user2@immich.app', password: 'Password123' });
+    user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' });
 
-    const user1Albums = await Promise.all([
+    user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
+    user1Albums = await Promise.all([
       api.albumApi.create(server, user1.accessToken, {
         albumName: user1SharedUser,
         sharedWithUserIds: [user2.userId],
+        assetIds: [user1Asset.id],
       }),
-      api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink }),
-      api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared }),
+      api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
+      api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
     ]);
 
     // add shared link to user1SharedLink album
@@ -65,10 +72,11 @@ describe(`${AlbumController.name} (e2e)`, () => {
       albumId: user1Albums[1].id,
     });
 
-    const user2Albums = await Promise.all([
+    user2Albums = await Promise.all([
       api.albumApi.create(server, user2.accessToken, {
         albumName: user2SharedUser,
         sharedWithUserIds: [user1.userId],
+        assetIds: [user1Asset.id],
       }),
       api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
       api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
@@ -150,31 +158,30 @@ describe(`${AlbumController.name} (e2e)`, () => {
       );
     });
 
-    // TODO: Add asset to album and test if it returns correctly.
     it('should return the album collection filtered by assetId', async () => {
+      const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
+      await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
       const { status, body } = await request(server)
-        .get('/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790')
+        .get(`/album?assetId=${asset.id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toEqual(200);
-      expect(body).toHaveLength(0);
+      expect(body).toHaveLength(1);
     });
 
-    // TODO: Add asset to album and test if it returns correctly.
     it('should return the album collection filtered by assetId and ignores shared=true', async () => {
       const { status, body } = await request(server)
-        .get('/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790')
+        .get(`/album?shared=true&assetId=${user1Asset.id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toEqual(200);
-      expect(body).toHaveLength(0);
+      expect(body).toHaveLength(4);
     });
 
-    // TODO: Add asset to album and test if it returns correctly.
     it('should return the album collection filtered by assetId and ignores shared=false', async () => {
       const { status, body } = await request(server)
-        .get('/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790')
+        .get(`/album?shared=false&assetId=${user1Asset.id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toEqual(200);
-      expect(body).toHaveLength(0);
+      expect(body).toHaveLength(4);
     });
   });
 
@@ -205,6 +212,79 @@ describe(`${AlbumController.name} (e2e)`, () => {
     });
   });
 
+  describe('GET /album/count', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/album/count');
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return total count of albums the user has access to', async () => {
+      const { status, body } = await request(server)
+        .get('/album/count')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
+    });
+  });
+
+  describe('GET /album/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return album info for own album', async () => {
+      const { status, body } = await request(server)
+        .get(`/album/${user1Albums[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual(user1Albums[0]);
+    });
+
+    it('should return album info for shared album', async () => {
+      const { status, body } = await request(server)
+        .get(`/album/${user2Albums[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual(user2Albums[0]);
+    });
+  });
+
+  describe('PUT /album/:id/assets', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should be able to add own asset to own album', async () => {
+      const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
+      const { status, body } = await request(server)
+        .put(`/album/${user1Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ ids: [asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
+    });
+
+    it('should be able to add own asset to shared album', async () => {
+      const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
+      const { status, body } = await request(server)
+        .put(`/album/${user2Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ ids: [asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
+    });
+  });
+
   describe('PATCH /album/:id', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server)
@@ -232,4 +312,107 @@ describe(`${AlbumController.name} (e2e)`, () => {
       });
     });
   });
+
+  describe('DELETE /album/:id/assets', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .delete(`/album/${user1Albums[0].id}/assets`)
+        .send({ ids: [user1Asset.id] });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should be able to remove own asset from own album', async () => {
+      const { status, body } = await request(server)
+        .delete(`/album/${user1Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ ids: [user1Asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
+    });
+
+    it('should be able to remove own asset from shared album', async () => {
+      const { status, body } = await request(server)
+        .delete(`/album/${user2Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ ids: [user1Asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
+    });
+
+    it('should not be able to remove foreign asset from own album', async () => {
+      const { status, body } = await request(server)
+        .delete(`/album/${user2Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user2.accessToken}`)
+        .send({ ids: [user1Asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
+    });
+
+    it('should not be able to remove foreign asset from foreign album', async () => {
+      const { status, body } = await request(server)
+        .delete(`/album/${user1Albums[0].id}/assets`)
+        .set('Authorization', `Bearer ${user2.accessToken}`)
+        .send({ ids: [user1Asset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
+    });
+  });
+
+  describe('PUT :id/users', () => {
+    let album: AlbumResponseDto;
+
+    beforeEach(async () => {
+      album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
+    });
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .put(`/album/${user1Albums[0].id}/users`)
+        .send({ sharedUserIds: [] });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should be able to add user to own album', async () => {
+      const { status, body } = await request(server)
+        .put(`/album/${album.id}/users`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ sharedUserIds: [user2.userId] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
+    });
+
+    // it('should not be able to share album with owner', async () => {
+    //   const { status, body } = await request(server)
+    //     .put(`/album/${album.id}/users`)
+    //     .set('Authorization', `Bearer ${user1.accessToken}`)
+    //     .send({ sharedUserIds: [user2.userId] });
+
+    //   expect(status).toBe(400);
+    //   expect(body).toEqual(errorStub.badRequest);
+    // });
+
+    it('should not be able to add existing user to shared album', async () => {
+      await request(server)
+        .put(`/album/${album.id}/users`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ sharedUserIds: [user2.userId] });
+
+      const { status, body } = await request(server)
+        .put(`/album/${album.id}/users`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ sharedUserIds: [user2.userId] });
+
+      expect(status).toBe(400);
+      expect(body).toEqual({ ...errorStub.badRequest, message: 'User already added' });
+    });
+  });
 });

+ 310 - 12
server/test/e2e/asset.e2e-spec.ts

@@ -1,12 +1,13 @@
-import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain';
+import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain';
 import { AppModule, AssetController } from '@app/immich';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub, uuidStub } from '@test/fixtures';
 import { randomBytes } from 'crypto';
 import request from 'supertest';
-import { errorStub, uuidStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 const user1Dto = {
   email: 'user1@immich.app',
@@ -22,8 +23,30 @@ const user2Dto = {
   lastName: 'Test',
 };
 
+const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
+  const dto: Record<string, any> = {
+    deviceAssetId: 'example-image',
+    deviceId: 'TEST',
+    fileCreatedAt: new Date().toISOString(),
+    fileModifiedAt: new Date().toISOString(),
+    isFavorite: 'testing',
+    duration: '0:00:00.000000',
+  };
+
+  const omit = options?.omit;
+  if (omit) {
+    delete dto[omit];
+  }
+
+  return dto;
+};
+
 let assetCount = 0;
-const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseDto): Promise<AssetEntity> => {
+const createAsset = (
+  repository: IAssetRepository,
+  loginResponse: LoginResponseDto,
+  createdAt: Date,
+): Promise<AssetEntity> => {
   const id = assetCount++;
   return repository.save({
     ownerId: loginResponse.userId,
@@ -31,7 +54,7 @@ const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseD
     originalPath: `/tests/test_${id}`,
     deviceAssetId: `test_${id}`,
     deviceId: 'e2e-test',
-    fileCreatedAt: new Date(),
+    fileCreatedAt: createdAt,
     fileModifiedAt: new Date(),
     type: AssetType.IMAGE,
     originalFileName: `test_${id}`,
@@ -46,6 +69,8 @@ describe(`${AssetController.name} (e2e)`, () => {
   let user2: LoginResponseDto;
   let asset1: AssetEntity;
   let asset2: AssetEntity;
+  let asset3: AssetEntity;
+  let asset4: AssetEntity;
 
   beforeAll(async () => {
     const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -59,16 +84,18 @@ describe(`${AssetController.name} (e2e)`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    const admin = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    const admin = await api.authApi.adminLogin(server);
 
     await api.userApi.create(server, admin.accessToken, user1Dto);
-    user1 = await api.login(server, { email: user1Dto.email, password: user1Dto.password });
-    asset1 = await createAsset(assetRepository, user1);
+    user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
+    asset1 = await createAsset(assetRepository, user1, new Date('1970-01-01'));
+    asset2 = await createAsset(assetRepository, user1, new Date('1970-01-02'));
+    asset3 = await createAsset(assetRepository, user1, new Date('1970-02-01'));
 
     await api.userApi.create(server, admin.accessToken, user2Dto);
-    user2 = await api.login(server, { email: user2Dto.email, password: user2Dto.password });
-    asset2 = await createAsset(assetRepository, user2);
+    user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password });
+    asset4 = await createAsset(assetRepository, user2, new Date('1970-01-01'));
   });
 
   afterAll(async () => {
@@ -76,6 +103,83 @@ describe(`${AssetController.name} (e2e)`, () => {
     await app.close();
   });
 
+  describe('POST /asset/upload', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .post(`/asset/upload`)
+        .field('deviceAssetId', 'example-image')
+        .field('deviceId', 'TEST')
+        .field('fileCreatedAt', new Date().toISOString())
+        .field('fileModifiedAt', new Date().toISOString())
+        .field('isFavorite', false)
+        .field('duration', '0:00:00.000000')
+        .attach('assetData', randomBytes(32), 'example.jpg');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    const invalid = [
+      { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } },
+      { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } },
+      { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } },
+      { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } },
+      { should: 'require `isFavorite`', dto: { ...makeUploadDto({ omit: 'isFavorite' }) } },
+      { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } },
+      { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } },
+      { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } },
+      { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } },
+    ];
+
+    for (const { should, dto } of invalid) {
+      it(`should ${should}`, async () => {
+        const { status, body } = await request(server)
+          .post('/asset/upload')
+          .set('Authorization', `Bearer ${user1.accessToken}`)
+          .attach('assetData', randomBytes(32), 'example.jpg')
+          .field(dto);
+        expect(status).toBe(400);
+        expect(body).toEqual(errorStub.badRequest);
+      });
+    }
+
+    it('should upload a new asset', async () => {
+      const { body, status } = await request(server)
+        .post('/asset/upload')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .field('deviceAssetId', 'example-image')
+        .field('deviceId', 'TEST')
+        .field('fileCreatedAt', new Date().toISOString())
+        .field('fileModifiedAt', new Date().toISOString())
+        .field('isFavorite', 'true')
+        .field('duration', '0:00:00.000000')
+        .attach('assetData', randomBytes(32), 'example.jpg');
+      expect(status).toBe(201);
+      expect(body).toEqual({ id: expect.any(String), duplicate: false });
+
+      const asset = await api.assetApi.get(server, user1.accessToken, body.id);
+      expect(asset).toMatchObject({ id: body.id, isFavorite: true });
+    });
+
+    it('should not upload the same asset twice', async () => {
+      const content = randomBytes(32);
+      await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
+      const { body, status } = await request(server)
+        .post('/asset/upload')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .field('deviceAssetId', 'example-image')
+        .field('deviceId', 'TEST')
+        .field('fileCreatedAt', new Date().toISOString())
+        .field('fileModifiedAt', new Date().toISOString())
+        .field('isFavorite', false)
+        .field('duration', '0:00:00.000000')
+        .attach('assetData', content, 'example.jpg');
+
+      expect(status).toBe(200);
+      expect(body.duplicate).toBe(true);
+    });
+  });
+
   describe('PUT /asset/:id', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`);
@@ -93,7 +197,7 @@ describe(`${AssetController.name} (e2e)`, () => {
 
     it('should require access', async () => {
       const { status, body } = await request(server)
-        .put(`/asset/${asset2.id}`)
+        .put(`/asset/${asset4.id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toBe(400);
       expect(body).toEqual(errorStub.noPermission);
@@ -160,4 +264,198 @@ describe(`${AssetController.name} (e2e)`, () => {
       });
     });
   });
+
+  describe('POST /asset/download/info', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .post(`/asset/download/info`)
+        .send({ assetIds: [asset1.id] });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should download info', async () => {
+      const { status, body } = await request(server)
+        .post('/asset/download/info')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ assetIds: [asset1.id] });
+
+      expect(status).toBe(201);
+      expect(body).toEqual(expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })] }));
+    });
+  });
+
+  describe('POST /asset/download/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).post(`/asset/download/${asset1.id}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should download file', async () => {
+      const asset = await api.assetApi.upload(server, user1.accessToken, 'example');
+      const response = await request(server)
+        .post(`/asset/download/${asset.id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(response.status).toBe(200);
+      expect(response.headers['content-type']).toEqual('image/jpeg');
+    });
+  });
+
+  describe('GET /asset/statistics', () => {
+    beforeEach(async () => {
+      await api.assetApi.upload(server, user1.accessToken, 'favored_asset', { isFavorite: true });
+      await api.assetApi.upload(server, user1.accessToken, 'archived_asset', { isArchived: true });
+      await api.assetApi.upload(server, user1.accessToken, 'favored_archived_asset', {
+        isFavorite: true,
+        isArchived: true,
+      });
+    });
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/album/statistics');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return stats of all assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/statistics')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ images: 6, videos: 0, total: 6 });
+    });
+
+    it('should return stats of all favored assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/statistics')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ isFavorite: true });
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ images: 2, videos: 0, total: 2 });
+    });
+
+    it('should return stats of all archived assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/statistics')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ isArchived: true });
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ images: 2, videos: 0, total: 2 });
+    });
+
+    it('should return stats of all favored and archived assets', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/statistics')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ isFavorite: true, isArchived: true });
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ images: 1, videos: 0, total: 1 });
+    });
+
+    it('should return stats of all assets neither favored nor archived', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/statistics')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ isFavorite: false, isArchived: false });
+
+      expect(status).toBe(200);
+      expect(body).toEqual({ images: 3, videos: 0, total: 3 });
+    });
+  });
+
+  describe('GET /asset/time-buckets', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should get time buckets by month', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/time-buckets')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ size: TimeBucketSize.MONTH });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(
+        expect.arrayContaining([
+          { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() },
+          { count: 2, timeBucket: asset1.fileCreatedAt.toISOString() },
+        ]),
+      );
+    });
+
+    it('should get time buckets by day', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/time-buckets')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ size: TimeBucketSize.DAY });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(
+        expect.arrayContaining([
+          { count: 1, timeBucket: asset1.fileCreatedAt.toISOString() },
+          { count: 1, timeBucket: asset2.fileCreatedAt.toISOString() },
+          { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() },
+        ]),
+      );
+    });
+  });
+
+  describe('GET /asset/time-bucket', () => {
+    let timeBucket: string;
+    beforeEach(async () => {
+      const { body, status } = await request(server)
+        .get('/asset/time-buckets')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ size: TimeBucketSize.MONTH });
+
+      expect(status).toBe(200);
+      timeBucket = body[1].timeBucket;
+    });
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/time-bucket')
+        .query({ size: TimeBucketSize.MONTH, timeBucket });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    // it('should fail if time bucket is invalid', async () => {
+    //   const { status, body } = await request(server)
+    //     .get('/asset/time-bucket')
+    //     .set('Authorization', `Bearer ${user1.accessToken}`)
+    //     .query({ size: TimeBucketSize.MONTH, timeBucket: 'foo' });
+
+    //   expect(status).toBe(400);
+    //   expect(body).toEqual(errorStub.badRequest);
+    // });
+
+    it('should return time bucket', async () => {
+      const { status, body } = await request(server)
+        .get('/asset/time-bucket')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ size: TimeBucketSize.MONTH, timeBucket });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({ id: asset1.id }),
+          expect.objectContaining({ id: asset2.id }),
+        ]),
+      );
+    });
+  });
 });

+ 14 - 13
server/test/e2e/auth.e2e-spec.ts

@@ -1,7 +1,8 @@
 import { AppModule, AuthController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
-import request from 'supertest';
+import { api } from '@test/api';
+import { db } from '@test/db';
 import {
   adminSignupStub,
   changePasswordStub,
@@ -11,8 +12,8 @@ import {
   loginStub,
   signupResponseStub,
   uuidStub,
-} from '../fixtures';
-import { api, db } from '../test-utils';
+} from '@test/fixtures';
+import request from 'supertest';
 
 const firstName = 'Immich';
 const lastName = 'Admin';
@@ -35,8 +36,8 @@ describe(`${AuthController.name} (e2e)`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    const response = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    const response = await api.authApi.adminLogin(server);
     accessToken = response.accessToken;
   });
 
@@ -67,7 +68,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     }
 
     it(`should sign up the admin`, async () => {
-      await api.adminSignUp(server);
+      await api.authApi.adminSignUp(server);
     });
 
     it('should sign up the admin with a local domain', async () => {
@@ -87,7 +88,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     });
 
     it('should not allow a second admin to sign up', async () => {
-      await api.adminSignUp(server);
+      await api.authApi.adminSignUp(server);
 
       const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
 
@@ -152,7 +153,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     });
   });
 
-  describe('DELETE /auth/devices/:id', () => {
+  describe('DELETE /auth/devices', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).delete(`/auth/devices`);
       expect(status).toBe(401);
@@ -161,15 +162,15 @@ describe(`${AuthController.name} (e2e)`, () => {
 
     it('should logout all devices (except the current one)', async () => {
       for (let i = 0; i < 5; i++) {
-        await api.adminLogin(server);
+        await api.authApi.adminLogin(server);
       }
 
-      await expect(api.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
+      await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
 
       const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`);
       expect(status).toBe(204);
 
-      await api.validateToken(server, accessToken);
+      await api.authApi.validateToken(server, accessToken);
     });
   });
 
@@ -181,7 +182,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     });
 
     it('should logout a device', async () => {
-      const [device] = await api.getAuthDevices(server, accessToken);
+      const [device] = await api.authApi.getAuthDevices(server, accessToken);
       const { status } = await request(server)
         .delete(`/auth/devices/${device.id}`)
         .set('Authorization', `Bearer ${accessToken}`);
@@ -244,7 +245,7 @@ describe(`${AuthController.name} (e2e)`, () => {
         .set('Authorization', `Bearer ${accessToken}`);
       expect(status).toBe(200);
 
-      await api.login(server, { email: 'admin@immich.app', password: 'Password1234' });
+      await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' });
     });
   });
 

+ 4 - 3
server/test/e2e/oauth.e2e-spec.ts

@@ -1,9 +1,10 @@
 import { AppModule, OAuthController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub } from '@test/fixtures';
 import request from 'supertest';
-import { errorStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 describe(`${OAuthController.name} (e2e)`, () => {
   let app: INestApplication;
@@ -20,7 +21,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
+    await api.authApi.adminSignUp(server);
   });
 
   afterAll(async () => {

+ 146 - 0
server/test/e2e/partner.e2e-spec.ts

@@ -0,0 +1,146 @@
+import { IPartnerRepository, LoginResponseDto, PartnerDirection } from '@app/domain';
+import { AppModule, PartnerController } from '@app/immich';
+import { INestApplication } from '@nestjs/common';
+import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub } from '@test/fixtures';
+import request from 'supertest';
+
+const user1Dto = {
+  email: 'user1@immich.app',
+  password: 'Password123',
+  firstName: 'User 1',
+  lastName: 'Test',
+};
+
+const user2Dto = {
+  email: 'user2@immich.app',
+  password: 'Password123',
+  firstName: 'User 2',
+  lastName: 'Test',
+};
+
+describe(`${PartnerController.name} (e2e)`, () => {
+  let app: INestApplication;
+  let server: any;
+  let loginResponse: LoginResponseDto;
+  let accessToken: string;
+  let repository: IPartnerRepository;
+  let user1: LoginResponseDto;
+  let user2: LoginResponseDto;
+
+  beforeAll(async () => {
+    const moduleFixture: TestingModule = await Test.createTestingModule({
+      imports: [AppModule],
+    }).compile();
+
+    app = await moduleFixture.createNestApplication().init();
+    server = app.getHttpServer();
+    repository = app.get<IPartnerRepository>(IPartnerRepository);
+  });
+
+  beforeEach(async () => {
+    await db.reset();
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
+    accessToken = loginResponse.accessToken;
+
+    await api.userApi.create(server, accessToken, user1Dto);
+    user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
+
+    await api.userApi.create(server, accessToken, user2Dto);
+    user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password });
+  });
+
+  afterAll(async () => {
+    await db.disconnect();
+    await app.close();
+  });
+
+  describe('GET /partner', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/partner');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should get all partners shared by user', async () => {
+      await repository.create({ sharedById: user1.userId, sharedWithId: user2.userId });
+      const { status, body } = await request(server)
+        .get('/partner')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ direction: PartnerDirection.SharedBy });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
+    });
+
+    it('should get all partners that share with user', async () => {
+      await repository.create({ sharedById: user2.userId, sharedWithId: user1.userId });
+      const { status, body } = await request(server)
+        .get('/partner')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .query({ direction: PartnerDirection.SharedWith });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
+    });
+  });
+
+  describe('POST /partner/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).post(`/partner/${user2.userId}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should share with new partner', async () => {
+      const { status, body } = await request(server)
+        .post(`/partner/${user2.userId}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(201);
+      expect(body).toEqual(expect.objectContaining({ id: user2.userId }));
+    });
+
+    it('should not share with new partner if already sharing with this partner', async () => {
+      await repository.create({ sharedById: user1.userId, sharedWithId: user2.userId });
+      const { status, body } = await request(server)
+        .post(`/partner/${user2.userId}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
+    });
+  });
+
+  describe('DELETE /partner/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).delete(`/partner/${user2.userId}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should delete partner', async () => {
+      await repository.create({ sharedById: user1.userId, sharedWithId: user2.userId });
+      const { status } = await request(server)
+        .delete(`/partner/${user2.userId}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+    });
+
+    it('should throw a bad request if partner not found', async () => {
+      const { status, body } = await request(server)
+        .delete(`/partner/${user2.userId}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
+    });
+  });
+});

+ 97 - 12
server/test/e2e/person.e2e-spec.ts

@@ -1,16 +1,22 @@
-import { IPersonRepository, LoginResponseDto } from '@app/domain';
+import { IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain';
 import { AppModule, PersonController } from '@app/immich';
+import { PersonEntity } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub, uuidStub } from '@test/fixtures';
 import request from 'supertest';
-import { errorStub, uuidStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 describe(`${PersonController.name}`, () => {
   let app: INestApplication;
   let server: any;
   let loginResponse: LoginResponseDto;
   let accessToken: string;
+  let personRepository: IPersonRepository;
+  let faceRepository: IFaceRepository;
+  let visiblePerson: PersonEntity;
+  let hiddenPerson: PersonEntity;
 
   beforeAll(async () => {
     const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -19,13 +25,31 @@ describe(`${PersonController.name}`, () => {
 
     app = await moduleFixture.createNestApplication().init();
     server = app.getHttpServer();
+    personRepository = app.get<IPersonRepository>(IPersonRepository);
+    faceRepository = app.get<IFaceRepository>(IFaceRepository);
   });
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    loginResponse = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
     accessToken = loginResponse.accessToken;
+
+    const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
+    visiblePerson = await personRepository.create({
+      ownerId: loginResponse.userId,
+      name: 'visible_person',
+      thumbnailPath: '/thumbnail/face_asset',
+    });
+    await faceRepository.create({ assetId: faceAsset.id, personId: visiblePerson.id });
+
+    hiddenPerson = await personRepository.create({
+      ownerId: loginResponse.userId,
+      name: 'hidden_person',
+      isHidden: true,
+      thumbnailPath: '/thumbnail/face_asset',
+    });
+    await faceRepository.create({ assetId: faceAsset.id, personId: hiddenPerson.id });
   });
 
   afterAll(async () => {
@@ -33,6 +57,72 @@ describe(`${PersonController.name}`, () => {
     await app.close();
   });
 
+  describe('GET /person', () => {
+    beforeEach(async () => {});
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/person');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return all people (including hidden)', async () => {
+      const { status, body } = await request(server)
+        .get('/person')
+        .set('Authorization', `Bearer ${accessToken}`)
+        .query({ withHidden: true });
+
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        total: 2,
+        visible: 1,
+        people: [
+          expect.objectContaining({ name: 'visible_person' }),
+          expect.objectContaining({ name: 'hidden_person' }),
+        ],
+      });
+    });
+
+    it('should return only visible people', async () => {
+      const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        total: 1,
+        visible: 1,
+        people: [expect.objectContaining({ name: 'visible_person' })],
+      });
+    });
+  });
+
+  describe('GET /person/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should throw error if person with id does not exist', async () => {
+      const { status, body } = await request(server)
+        .get(`/person/${uuidStub.notFound}`)
+        .set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest);
+    });
+
+    it('should return person information', async () => {
+      const { status, body } = await request(server)
+        .get(`/person/${visiblePerson.id}`)
+        .set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
+    });
+  });
+
   describe('PUT /person/:id', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
@@ -42,10 +132,8 @@ describe(`${PersonController.name}`, () => {
 
     for (const key of ['name', 'featureFaceAssetId', 'isHidden']) {
       it(`should not allow null ${key}`, async () => {
-        const personRepository = app.get<IPersonRepository>(IPersonRepository);
-        const person = await personRepository.create({ ownerId: loginResponse.userId });
         const { status, body } = await request(server)
-          .put(`/person/${person.id}`)
+          .put(`/person/${visiblePerson.id}`)
           .set('Authorization', `Bearer ${accessToken}`)
           .send({ [key]: null });
         expect(status).toBe(400);
@@ -65,10 +153,8 @@ describe(`${PersonController.name}`, () => {
     });
 
     it('should update a date of birth', async () => {
-      const personRepository = app.get<IPersonRepository>(IPersonRepository);
-      const person = await personRepository.create({ ownerId: loginResponse.userId });
       const { status, body } = await request(server)
-        .put(`/person/${person.id}`)
+        .put(`/person/${visiblePerson.id}`)
         .set('Authorization', `Bearer ${accessToken}`)
         .send({ birthDate: '1990-01-01T05:00:00.000Z' });
       expect(status).toBe(200);
@@ -76,7 +162,6 @@ describe(`${PersonController.name}`, () => {
     });
 
     it('should clear a date of birth', async () => {
-      const personRepository = app.get<IPersonRepository>(IPersonRepository);
       const person = await personRepository.create({
         birthDate: new Date('1990-01-01'),
         ownerId: loginResponse.userId,

+ 6 - 5
server/test/e2e/server-info.e2e-spec.ts

@@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain';
 import { AppModule, ServerInfoController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub } from '@test/fixtures';
 import request from 'supertest';
-import { errorStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 describe(`${ServerInfoController.name} (e2e)`, () => {
   let app: INestApplication;
@@ -23,8 +24,8 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    loginResponse = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
     accessToken = loginResponse.accessToken;
   });
 
@@ -116,7 +117,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
     it('should only work for admins', async () => {
       const loginDto = { email: 'test@immich.app', password: 'Immich123' };
       await api.userApi.create(server, accessToken, { ...loginDto, firstName: 'test', lastName: 'test' });
-      const { accessToken: userAccessToken } = await api.login(server, loginDto);
+      const { accessToken: userAccessToken } = await api.authApi.login(server, loginDto);
       const { status, body } = await request(server)
         .get('/server-info/stats')
         .set('Authorization', `Bearer ${userAccessToken}`);

+ 241 - 0
server/test/e2e/shared-link.e2e-spec.ts

@@ -0,0 +1,241 @@
+import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
+import { AppModule, PartnerController } from '@app/immich';
+import { SharedLinkType } from '@app/infra/entities';
+import { INestApplication } from '@nestjs/common';
+import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub, uuidStub } from '@test/fixtures';
+import request from 'supertest';
+
+const user1Dto = {
+  email: 'user1@immich.app',
+  password: 'Password123',
+  firstName: 'User 1',
+  lastName: 'Test',
+};
+
+describe(`${PartnerController.name} (e2e)`, () => {
+  let app: INestApplication;
+  let server: any;
+  let loginResponse: LoginResponseDto;
+  let accessToken: string;
+  let user1: LoginResponseDto;
+  let album: AlbumResponseDto;
+  let sharedLink: SharedLinkResponseDto;
+
+  beforeAll(async () => {
+    const moduleFixture: TestingModule = await Test.createTestingModule({
+      imports: [AppModule],
+    }).compile();
+
+    app = await moduleFixture.createNestApplication().init();
+    server = app.getHttpServer();
+  });
+
+  beforeEach(async () => {
+    await db.reset();
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
+    accessToken = loginResponse.accessToken;
+
+    await api.userApi.create(server, accessToken, user1Dto);
+    user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
+
+    album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' });
+    sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
+      type: SharedLinkType.ALBUM,
+      albumId: album.id,
+    });
+  });
+
+  afterAll(async () => {
+    await db.disconnect();
+    await app.close();
+  });
+
+  describe('GET /shared-link', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/shared-link');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should get all shared links created by user', async () => {
+      const { status, body } = await request(server)
+        .get('/shared-link')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })]);
+    });
+
+    it('should not get shared links created by other users', async () => {
+      const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual([]);
+    });
+  });
+
+  describe('GET /shared-link/me', () => {
+    it('should not require admin authentication', async () => {
+      const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(403);
+    });
+
+    it('should get data for correct shared link', async () => {
+      const { status, body } = await request(server).get('/shared-link/me').query({ key: sharedLink.key });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
+    });
+
+    it('should return unauthorized for incorrect shared link', async () => {
+      const { status, body } = await request(server)
+        .get('/shared-link/me')
+        .query({ key: sharedLink.key + 'foo' });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(expect.objectContaining({ message: 'Invalid share key' }));
+    });
+  });
+
+  describe('GET /shared-link/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get(`/shared-link/${sharedLink.id}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should get shared link by id', async () => {
+      const { status, body } = await request(server)
+        .get(`/shared-link/${sharedLink.id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
+    });
+
+    it('should not get shared link by id if user has not created the link or it does not exist', async () => {
+      const { status, body } = await request(server)
+        .get(`/shared-link/${sharedLink.id}`)
+        .set('Authorization', `Bearer ${accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
+    });
+  });
+
+  describe('POST /shared-link', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .post('/shared-link')
+        .send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should require a type and the correspondent asset/album id', async () => {
+      const { status, body } = await request(server)
+        .post('/shared-link')
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest);
+    });
+
+    it('should require an asset/album id', async () => {
+      const { status, body } = await request(server)
+        .post('/shared-link')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ type: SharedLinkType.ALBUM });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
+    });
+
+    it('should require a valid asset id', async () => {
+      const { status, body } = await request(server)
+        .post('/shared-link')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
+    });
+
+    it('should create a shared link', async () => {
+      const { status, body } = await request(server)
+        .post('/shared-link')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ type: SharedLinkType.ALBUM, albumId: album.id });
+
+      expect(status).toBe(201);
+      expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId }));
+    });
+  });
+
+  describe('PATCH /shared-link/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .patch(`/shared-link/${sharedLink.id}`)
+        .send({ description: 'foo' });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should fail if invalid link', async () => {
+      const { status, body } = await request(server)
+        .patch(`/shared-link/${uuidStub.notFound}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ description: 'foo' });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest);
+    });
+
+    it('should update shared link', async () => {
+      const { status, body } = await request(server)
+        .patch(`/shared-link/${sharedLink.id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ description: 'foo' });
+
+      expect(status).toBe(200);
+      expect(body).toEqual(
+        expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }),
+      );
+    });
+  });
+
+  describe('DELETE /shared-link/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).delete(`/shared-link/${sharedLink.id}`);
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should fail if invalid link', async () => {
+      const { status, body } = await request(server)
+        .delete(`/shared-link/${uuidStub.notFound}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest);
+    });
+
+    it('should update shared link', async () => {
+      const { status } = await request(server)
+        .delete(`/shared-link/${sharedLink.id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`);
+
+      expect(status).toBe(200);
+    });
+  });
+});

+ 5 - 4
server/test/e2e/user.e2e-spec.ts

@@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain';
 import { AppModule, UserController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
+import { api } from '@test/api';
+import { db } from '@test/db';
+import { errorStub, userSignupStub, userStub } from '@test/fixtures';
 import request from 'supertest';
-import { errorStub, userSignupStub, userStub } from '../fixtures';
-import { api, db } from '../test-utils';
 
 describe(`${UserController.name}`, () => {
   let app: INestApplication;
@@ -23,8 +24,8 @@ describe(`${UserController.name}`, () => {
 
   beforeEach(async () => {
     await db.reset();
-    await api.adminSignUp(server);
-    loginResponse = await api.adminLogin(server);
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
     accessToken = loginResponse.accessToken;
   });
 

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

@@ -1,4 +1,6 @@
 import { SearchResult } from '@app/domain';
+import { AssetEntity } from '@app/infra/entities';
+import { assetStub } from '.';
 
 export const searchStub = {
   emptyResults: Object.freeze<SearchResult<any>>({
@@ -9,4 +11,13 @@ export const searchStub = {
     facets: [],
     distances: [],
   }),
+
+  withImage: Object.freeze<SearchResult<AssetEntity>>({
+    total: 1,
+    count: 1,
+    page: 1,
+    items: [assetStub.image],
+    facets: [],
+    distances: [],
+  }),
 };

+ 0 - 155
server/test/test-utils.ts

@@ -1,155 +0,0 @@
-import {
-  AdminSignupResponseDto,
-  AlbumResponseDto,
-  AuthDeviceResponseDto,
-  AuthUserDto,
-  CreateAlbumDto,
-  CreateUserDto,
-  LoginCredentialDto,
-  LoginResponseDto,
-  SharedLinkCreateDto,
-  SharedLinkResponseDto,
-  UpdateUserDto,
-  UserResponseDto,
-} from '@app/domain';
-import { dataSource } from '@app/infra';
-import request from 'supertest';
-import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from './fixtures';
-
-export const db = {
-  reset: async () => {
-    if (!dataSource.isInitialized) {
-      await dataSource.initialize();
-    }
-
-    await dataSource.transaction(async (em) => {
-      for (const entity of dataSource.entityMetadatas) {
-        if (entity.tableName === 'users') {
-          continue;
-        }
-        await em.query(`DELETE FROM ${entity.tableName} CASCADE;`);
-      }
-      await em.query(`DELETE FROM "users" CASCADE;`);
-    });
-  },
-  disconnect: async () => {
-    if (dataSource.isInitialized) {
-      await dataSource.destroy();
-    }
-  },
-};
-
-export function getAuthUser(): AuthUserDto {
-  return {
-    id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750',
-    email: 'test@email.com',
-    isAdmin: false,
-  };
-}
-
-export const api = {
-  adminSignUp: async (server: any) => {
-    const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
-
-    expect(status).toBe(201);
-    expect(body).toEqual(signupResponseStub);
-
-    return body as AdminSignupResponseDto;
-  },
-  adminLogin: async (server: any) => {
-    const { status, body } = await request(server).post('/auth/login').send(loginStub.admin);
-
-    expect(body).toEqual(loginResponseStub.admin.response);
-    expect(body).toMatchObject({ accessToken: expect.any(String) });
-    expect(status).toBe(201);
-
-    return body as LoginResponseDto;
-  },
-  login: async (server: any, dto: LoginCredentialDto) => {
-    const { status, body } = await request(server).post('/auth/login').send(dto);
-
-    expect(status).toEqual(201);
-    expect(body).toMatchObject({ accessToken: expect.any(String) });
-
-    return body as LoginResponseDto;
-  },
-  getAuthDevices: async (server: any, accessToken: string) => {
-    const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
-
-    expect(body).toEqual(expect.any(Array));
-    expect(status).toBe(200);
-
-    return body as AuthDeviceResponseDto[];
-  },
-  validateToken: async (server: any, accessToken: string) => {
-    const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
-    expect(response.body).toEqual({ authStatus: true });
-    expect(response.status).toBe(200);
-  },
-  albumApi: {
-    create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
-      const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
-      expect(res.status).toEqual(201);
-      return res.body as AlbumResponseDto;
-    },
-  },
-  sharedLinkApi: {
-    create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
-      const { status, body } = await request(server)
-        .post('/shared-link')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-      expect(status).toBe(201);
-      return body as SharedLinkResponseDto;
-    },
-  },
-  userApi: {
-    create: async (server: any, accessToken: string, dto: CreateUserDto) => {
-      const { status, body } = await request(server)
-        .post('/user')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-
-      expect(status).toBe(201);
-      expect(body).toMatchObject({
-        id: expect.any(String),
-        createdAt: expect.any(String),
-        updatedAt: expect.any(String),
-        email: dto.email,
-      });
-
-      return body as UserResponseDto;
-    },
-    get: async (server: any, accessToken: string, id: string) => {
-      const { status, body } = await request(server)
-        .get(`/user/info/${id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id });
-
-      return body as UserResponseDto;
-    },
-    update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
-      const { status, body } = await request(server)
-        .put('/user')
-        .set('Authorization', `Bearer ${accessToken}`)
-        .send(dto);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id: dto.id });
-
-      return body as UserResponseDto;
-    },
-    delete: async (server: any, accessToken: string, id: string) => {
-      const { status, body } = await request(server)
-        .delete(`/user/${id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
-
-      expect(status).toBe(200);
-      expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
-
-      return body as UserResponseDto;
-    },
-  },
-} as const;