mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -153,44 +153,30 @@ describe(AlbumService.name, () => { | |||||||
|   describe('create', () => { |   describe('create', () => { | ||||||
|     it('creates album', async () => { |     it('creates album', async () => { | ||||||
|       albumMock.create.mockResolvedValue(albumStub.empty); |       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', |         albumName: 'Empty album', | ||||||
|  |         sharedWithUserIds: ['user-id'], | ||||||
|         description: '', |         description: '', | ||||||
|         albumThumbnailAssetId: null, |         assetIds: ['123'], | ||||||
|         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(), |  | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ |       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||||
|         name: JobName.SEARCH_INDEX_ALBUM, |         name: JobName.SEARCH_INDEX_ALBUM, | ||||||
|         data: { ids: [albumStub.empty.id] }, |         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 () => { |     it('should require valid userIds', async () => { | ||||||
|   | |||||||
| @@ -136,9 +136,6 @@ export class AlbumService { | |||||||
|     await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); |     await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); | ||||||
|  |  | ||||||
|     const album = await this.findOrFail(id, { withAssets: false }); |     const album = await this.findOrFail(id, { withAssets: false }); | ||||||
|     if (!album) { |  | ||||||
|       throw new BadRequestException('Album not found'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     await this.albumRepository.delete(album); |     await this.albumRepository.delete(album); | ||||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); |     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 }); |     const album = await this.findOrFail(id, { withAssets: false }); | ||||||
|  |  | ||||||
|     for (const userId of dto.sharedUserIds) { |     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); |       const exists = album.sharedUsers.find((user) => user.id === userId); | ||||||
|       if (exists) { |       if (exists) { | ||||||
|         throw new BadRequestException('User already added'); |         throw new BadRequestException('User already added'); | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ import { Readable } from 'stream'; | |||||||
| import { ICryptoRepository } from '../crypto'; | import { ICryptoRepository } from '../crypto'; | ||||||
| import { IJobRepository, JobName } from '../job'; | import { IJobRepository, JobName } from '../job'; | ||||||
| import { IStorageRepository } from '../storage'; | import { IStorageRepository } from '../storage'; | ||||||
| import { AssetStats, IAssetRepository } from './asset.repository'; | import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository'; | ||||||
| import { AssetService, UploadFieldName } from './asset.service'; | import { AssetService, UploadFieldName } from './asset.service'; | ||||||
| import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; | import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; | ||||||
| import { mapAsset } from './response-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', () => { |   describe('downloadFile', () => { | ||||||
|     it('should require the asset.download permission', async () => { |     it('should require the asset.download permission', async () => { | ||||||
|       accessMock.asset.hasOwnerAccess.mockResolvedValue(false); |       accessMock.asset.hasOwnerAccess.mockResolvedValue(false); | ||||||
|   | |||||||
| @@ -214,6 +214,15 @@ describe('AuthService', () => { | |||||||
|  |  | ||||||
|       expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123'); |       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', () => { |   describe('adminSignUp', () => { | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Colorspace } from '@app/infra/entities'; | import { Colorspace, SystemConfigKey } from '@app/infra/entities'; | ||||||
| import { | import { | ||||||
|   assetStub, |   assetStub, | ||||||
|   faceStub, |   faceStub, | ||||||
| @@ -137,6 +137,14 @@ describe(FacialRecognitionService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleQueueRecognizeFaces', () => { |   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 () => { |     it('should queue missing assets', async () => { | ||||||
|       assetMock.getWithout.mockResolvedValue({ |       assetMock.getWithout.mockResolvedValue({ | ||||||
|         items: [assetStub.image], |         items: [assetStub.image], | ||||||
| @@ -170,6 +178,14 @@ describe(FacialRecognitionService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleRecognizeFaces', () => { |   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 () => { |     it('should skip when no resize path', async () => { | ||||||
|       assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); |       assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); | ||||||
|       await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id }); |       await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id }); | ||||||
| @@ -260,6 +276,14 @@ describe(FacialRecognitionService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleGenerateFaceThumbnail', () => { |   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 () => { |     it('should skip an asset not found', async () => { | ||||||
|       assetMock.getByIds.mockResolvedValue([]); |       assetMock.getByIds.mockResolvedValue([]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -288,6 +288,17 @@ describe(JobService.name, () => { | |||||||
|           JobName.VIDEO_CONVERSION, |           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' } }, |         item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } }, | ||||||
|         jobs: [JobName.SEARCH_INDEX_ASSET], |         jobs: [JobName.SEARCH_INDEX_ASSET], | ||||||
| @@ -305,7 +316,11 @@ describe(JobService.name, () => { | |||||||
|     for (const { item, jobs } of tests) { |     for (const { item, jobs } of tests) { | ||||||
|       it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { |       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') { |         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 { |         } else { | ||||||
|           assetMock.getByIds.mockResolvedValue([]); |           assetMock.getByIds.mockResolvedValue([]); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -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', () => { |   describe('updateAll', () => { | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { BadRequestException } from '@nestjs/common'; | ||||||
| import { | import { | ||||||
|   albumStub, |   albumStub, | ||||||
|   assetStub, |   assetStub, | ||||||
| @@ -15,12 +16,13 @@ import { | |||||||
| } from '@test'; | } from '@test'; | ||||||
| import { plainToInstance } from 'class-transformer'; | import { plainToInstance } from 'class-transformer'; | ||||||
| import { IAlbumRepository } from '../album/album.repository'; | import { IAlbumRepository } from '../album/album.repository'; | ||||||
|  | import { mapAsset } from '../asset'; | ||||||
| import { IAssetRepository } from '../asset/asset.repository'; | import { IAssetRepository } from '../asset/asset.repository'; | ||||||
| import { IFaceRepository } from '../facial-recognition'; | import { IFaceRepository } from '../facial-recognition'; | ||||||
| import { ISystemConfigRepository } from '../index'; |  | ||||||
| import { JobName } from '../job'; | import { JobName } from '../job'; | ||||||
| import { IJobRepository } from '../job/job.repository'; | import { IJobRepository } from '../job/job.repository'; | ||||||
| import { IMachineLearningRepository } from '../smart-info'; | import { IMachineLearningRepository } from '../smart-info'; | ||||||
|  | import { ISystemConfigRepository } from '../system-config'; | ||||||
| import { SearchDto } from './dto'; | import { SearchDto } from './dto'; | ||||||
| import { ISearchRepository } from './search.repository'; | import { ISearchRepository } from './search.repository'; | ||||||
| import { SearchService } from './search.service'; | import { SearchService } from './search.service'; | ||||||
| @@ -50,9 +52,17 @@ describe(SearchService.name, () => { | |||||||
|  |  | ||||||
|     searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); |     searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); | ||||||
|  |  | ||||||
|  |     delete process.env.TYPESENSE_ENABLED; | ||||||
|     await sut.init(); |     await sut.init(); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const disableSearch = () => { | ||||||
|  |     searchMock.setup.mockClear(); | ||||||
|  |     searchMock.checkMigrationStatus.mockClear(); | ||||||
|  |     jobMock.queue.mockClear(); | ||||||
|  |     process.env.TYPESENSE_ENABLED = 'false'; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   afterEach(() => { |   afterEach(() => { | ||||||
|     sut.teardown(); |     sut.teardown(); | ||||||
|   }); |   }); | ||||||
| @@ -84,15 +94,14 @@ describe(SearchService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe(`init`, () => { |   describe(`init`, () => { | ||||||
|     // it('should skip when search is disabled', async () => { |     it('should skip when search is disabled', async () => { | ||||||
|     //   await sut.init(); |       disableSearch(); | ||||||
|  |       await sut.init(); | ||||||
|  |  | ||||||
|     //   expect(searchMock.setup).not.toHaveBeenCalled(); |       expect(searchMock.setup).not.toHaveBeenCalled(); | ||||||
|     //   expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); |       expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); | ||||||
|     //   expect(jobMock.queue).not.toHaveBeenCalled(); |       expect(jobMock.queue).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|     //   sut.teardown(); |  | ||||||
|     // }); |  | ||||||
|  |  | ||||||
|     it('should skip schema migration if not needed', async () => { |     it('should skip schema migration if not needed', async () => { | ||||||
|       await sut.init(); |       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', () => { |   describe('search', () => { | ||||||
|     // it('should throw an error is search is disabled', async () => { |     // it('should throw an error is search is disabled', async () => { | ||||||
|     //   sut['enabled'] = false; |     //   sut['enabled'] = false; | ||||||
| @@ -124,12 +156,40 @@ describe(SearchService.name, () => { | |||||||
|     //   expect(searchMock.searchAssets).not.toHaveBeenCalled(); |     //   expect(searchMock.searchAssets).not.toHaveBeenCalled(); | ||||||
|     // }); |     // }); | ||||||
|  |  | ||||||
|     it('should search assets and albums', async () => { |     it('should search assets and albums using text search', async () => { | ||||||
|       searchMock.searchAssets.mockResolvedValue(searchStub.emptyResults); |       searchMock.searchAssets.mockResolvedValue(searchStub.withImage); | ||||||
|       searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); |       searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); | ||||||
|       searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults); |       assetMock.getByIds.mockResolvedValue([assetStub.image]); | ||||||
|  |  | ||||||
|       await expect(sut.search(authStub.admin, {})).resolves.toEqual({ |       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: { |         albums: { | ||||||
|           total: 0, |           total: 0, | ||||||
|           count: 0, |           count: 0, | ||||||
| @@ -148,8 +208,17 @@ describe(SearchService.name, () => { | |||||||
|         }, |         }, | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); |       expect(machineMock.encodeText).toHaveBeenCalledWith(expect.any(String), { text: 'foo' }, expect.any(Object)); | ||||||
|       expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); |       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', | ||||||
|  |       }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { AssetEntity } from '@app/infra/entities'; | import { AssetEntity, SystemConfigKey } from '@app/infra/entities'; | ||||||
| import { | import { | ||||||
|   assetStub, |   assetStub, | ||||||
|   newAssetRepositoryMock, |   newAssetRepositoryMock, | ||||||
| @@ -43,6 +43,15 @@ describe(SmartInfoService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleQueueObjectTagging', () => { |   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 () => { |     it('should queue the assets without tags', async () => { | ||||||
|       assetMock.getWithout.mockResolvedValue({ |       assetMock.getWithout.mockResolvedValue({ | ||||||
|         items: [assetStub.image], |         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 () => { |     it('should skip assets without a resize path', async () => { | ||||||
|       const asset = { resizePath: '' } as AssetEntity; |       const asset = { resizePath: '' } as AssetEntity; | ||||||
|       assetMock.getByIds.mockResolvedValue([asset]); |       assetMock.getByIds.mockResolvedValue([asset]); | ||||||
| @@ -108,6 +126,15 @@ describe(SmartInfoService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleQueueEncodeClip', () => { |   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 () => { |     it('should queue the assets without clip embeddings', async () => { | ||||||
|       assetMock.getWithout.mockResolvedValue({ |       assetMock.getWithout.mockResolvedValue({ | ||||||
|         items: [assetStub.image], |         items: [assetStub.image], | ||||||
| @@ -134,6 +161,15 @@ describe(SmartInfoService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('handleEncodeClip', () => { |   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 () => { |     it('should skip assets without a resize path', async () => { | ||||||
|       const asset = { resizePath: '' } as AssetEntity; |       const asset = { resizePath: '' } as AssetEntity; | ||||||
|       assetMock.getByIds.mockResolvedValue([asset]); |       assetMock.getByIds.mockResolvedValue([asset]); | ||||||
|   | |||||||
| @@ -34,6 +34,41 @@ describe(StorageTemplateService.name, () => { | |||||||
|     sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock); |     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', () => { |   describe('handle template migration', () => { | ||||||
|     it('should handle no assets', async () => { |     it('should handle no assets', async () => { | ||||||
|       assetMock.getAll.mockResolvedValue({ |       assetMock.getAll.mockResolvedValue({ | ||||||
|   | |||||||
| @@ -89,7 +89,6 @@ export class StorageTemplateService { | |||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // TODO: use asset core (once in domain) |  | ||||||
|   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { |   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { | ||||||
|     if (asset.isReadOnly) { |     if (asset.isReadOnly) { | ||||||
|       this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); |       this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); | ||||||
| @@ -121,7 +120,7 @@ export class StorageTemplateService { | |||||||
|             error?.stack, |             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); |           await this.storageRepository.moveFile(destination, source); | ||||||
|  |  | ||||||
|           if (asset.sidecarPath && sidecarDestination && sidecarMoved) { |           if (asset.sidecarPath && sidecarDestination && sidecarMoved) { | ||||||
|   | |||||||
| @@ -1,5 +1,10 @@ | |||||||
| import { UserEntity } from '@app/infra/entities'; | import { UserEntity } from '@app/infra/entities'; | ||||||
| import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; | import { | ||||||
|  |   BadRequestException, | ||||||
|  |   ForbiddenException, | ||||||
|  |   InternalServerErrorException, | ||||||
|  |   NotFoundException, | ||||||
|  | } from '@nestjs/common'; | ||||||
| import { | import { | ||||||
|   newAlbumRepositoryMock, |   newAlbumRepositoryMock, | ||||||
|   newAssetRepositoryMock, |   newAssetRepositoryMock, | ||||||
| @@ -7,6 +12,7 @@ import { | |||||||
|   newJobRepositoryMock, |   newJobRepositoryMock, | ||||||
|   newStorageRepositoryMock, |   newStorageRepositoryMock, | ||||||
|   newUserRepositoryMock, |   newUserRepositoryMock, | ||||||
|  |   userStub, | ||||||
| } from '@test'; | } from '@test'; | ||||||
| import { when } from 'jest-when'; | import { when } from 'jest-when'; | ||||||
| import { IAlbumRepository } from '../album'; | import { IAlbumRepository } from '../album'; | ||||||
| @@ -16,7 +22,7 @@ import { ICryptoRepository } from '../crypto'; | |||||||
| import { IJobRepository, JobName } from '../job'; | import { IJobRepository, JobName } from '../job'; | ||||||
| import { IStorageRepository } from '../storage'; | import { IStorageRepository } from '../storage'; | ||||||
| import { UpdateUserDto } from './dto/update-user.dto'; | import { UpdateUserDto } from './dto/update-user.dto'; | ||||||
| import { UserResponseDto } from './response-dto'; | import { UserResponseDto, mapUser } from './response-dto'; | ||||||
| import { IUserRepository } from './user.repository'; | import { IUserRepository } from './user.repository'; | ||||||
| import { UserService } from './user.service'; | import { UserService } from './user.service'; | ||||||
|  |  | ||||||
| @@ -216,6 +222,13 @@ describe(UserService.name, () => { | |||||||
|       expect(userMock.getList).toHaveBeenCalled(); |       expect(userMock.getList).toHaveBeenCalled(); | ||||||
|       expect(response).toEqual({ userCount: 1 }); |       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', () => { |   describe('update', () => { | ||||||
| @@ -223,12 +236,17 @@ describe(UserService.name, () => { | |||||||
|       const update: UpdateUserDto = { |       const update: UpdateUserDto = { | ||||||
|         id: immichUser.id, |         id: immichUser.id, | ||||||
|         shouldChangePassword: true, |         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, isAdmin: true }, update); | ||||||
|  |  | ||||||
|       const updatedUser = await sut.update(immichUserAuth, update); |  | ||||||
|       expect(updatedUser.shouldChangePassword).toEqual(true); |       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 () => { |     it('should not set an empty string for storage label', async () => { | ||||||
| @@ -345,20 +363,37 @@ describe(UserService.name, () => { | |||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe('restore', () => { |   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 () => { |     it('should require an admin', async () => { | ||||||
|       when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser); |       when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser); | ||||||
|       await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException); |       await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||||
|       expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true); |       expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     it('should require the auth user be an admin', async () => { |     it('should restore an user', async () => { | ||||||
|       await expect(sut.delete(immichUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); |       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', () => { |   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 () => { |     it('cannot delete admin user', async () => { | ||||||
|       await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); |       await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||||
|     }); |     }); | ||||||
| @@ -368,9 +403,18 @@ describe(UserService.name, () => { | |||||||
|  |  | ||||||
|       expect(userMock.delete).not.toHaveBeenCalled(); |       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 () => { |     it('should not create a user if there is no local admin account', async () => { | ||||||
|       when(userMock.getAdmin).calledWith().mockResolvedValueOnce(null); |       when(userMock.getAdmin).calledWith().mockResolvedValueOnce(null); | ||||||
|  |  | ||||||
| @@ -383,6 +427,30 @@ describe(UserService.name, () => { | |||||||
|         }), |         }), | ||||||
|       ).rejects.toBeInstanceOf(BadRequestException); |       ).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', () => { |   describe('createProfileImage', () => { | ||||||
| @@ -394,6 +462,13 @@ describe(UserService.name, () => { | |||||||
|  |  | ||||||
|       expect(userMock.update).toHaveBeenCalledWith(adminUserAuth.id, { profileImagePath: file.path }); |       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', () => { |   describe('getUserProfileImage', () => { | ||||||
|   | |||||||
| @@ -63,7 +63,7 @@ export class AssetController { | |||||||
|   async uploadFile( |   async uploadFile( | ||||||
|     @AuthUser() authUser: AuthUserDto, |     @AuthUser() authUser: AuthUserDto, | ||||||
|     @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, |     @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, |     @Response({ passthrough: true }) res: Res, | ||||||
|   ): Promise<AssetFileUploadResponseDto> { |   ): Promise<AssetFileUploadResponseDto> { | ||||||
|     const file = mapToUploadFile(files.assetData[0]); |     const file = mapToUploadFile(files.assetData[0]); | ||||||
| @@ -90,7 +90,7 @@ export class AssetController { | |||||||
|   @Post('import') |   @Post('import') | ||||||
|   async importFile( |   async importFile( | ||||||
|     @AuthUser() authUser: AuthUserDto, |     @AuthUser() authUser: AuthUserDto, | ||||||
|     @Body(new ValidationPipe()) dto: ImportAssetDto, |     @Body(new ValidationPipe({ transform: true })) dto: ImportAssetDto, | ||||||
|     @Response({ passthrough: true }) res: Res, |     @Response({ passthrough: true }) res: Res, | ||||||
|   ): Promise<AssetFileUploadResponseDto> { |   ): Promise<AssetFileUploadResponseDto> { | ||||||
|     const responseDto = await this.assetService.importFile(authUser, dto); |     const responseDto = await this.assetService.importFile(authUser, dto); | ||||||
|   | |||||||
| @@ -1,33 +1,43 @@ | |||||||
| import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain'; | import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain'; | ||||||
| import { ApiProperty } from '@nestjs/swagger'; | import { ApiProperty } from '@nestjs/swagger'; | ||||||
| import { Transform } from 'class-transformer'; | import { Transform, Type } from 'class-transformer'; | ||||||
| import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; | import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator'; | ||||||
|  |  | ||||||
| export class CreateAssetBase { | export class CreateAssetBase { | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|  |   @IsString() | ||||||
|   deviceAssetId!: string; |   deviceAssetId!: string; | ||||||
|  |  | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|  |   @IsString() | ||||||
|   deviceId!: string; |   deviceId!: string; | ||||||
|  |  | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|  |   @IsDate() | ||||||
|  |   @Type(() => Date) | ||||||
|   fileCreatedAt!: Date; |   fileCreatedAt!: Date; | ||||||
|  |  | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|  |   @IsDate() | ||||||
|  |   @Type(() => Date) | ||||||
|   fileModifiedAt!: Date; |   fileModifiedAt!: Date; | ||||||
|  |  | ||||||
|   @IsNotEmpty() |   @IsBoolean() | ||||||
|  |   @Transform(toBoolean) | ||||||
|   isFavorite!: boolean; |   isFavorite!: boolean; | ||||||
|  |  | ||||||
|   @Optional() |   @Optional() | ||||||
|   @IsBoolean() |   @IsBoolean() | ||||||
|  |   @Transform(toBoolean) | ||||||
|   isArchived?: boolean; |   isArchived?: boolean; | ||||||
|  |  | ||||||
|   @Optional() |   @Optional() | ||||||
|   @IsBoolean() |   @IsBoolean() | ||||||
|  |   @Transform(toBoolean) | ||||||
|   isVisible?: boolean; |   isVisible?: boolean; | ||||||
|  |  | ||||||
|   @Optional() |   @Optional() | ||||||
|  |   @IsString() | ||||||
|   duration?: string; |   duration?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -51,6 +61,7 @@ export class CreateAssetDto extends CreateAssetBase { | |||||||
|  |  | ||||||
| export class ImportAssetDto extends CreateAssetBase { | export class ImportAssetDto extends CreateAssetBase { | ||||||
|   @Optional() |   @Optional() | ||||||
|  |   @IsBoolean() | ||||||
|   @Transform(toBoolean) |   @Transform(toBoolean) | ||||||
|   isReadOnly?: boolean = true; |   isReadOnly?: boolean = true; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								server/test/api/album-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								server/test/api/album-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/api/asset-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								server/test/api/asset-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/api/auth-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								server/test/api/auth-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/api/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/test/api/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/api/shared-link-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/test/api/shared-link-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/api/user-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								server/test/api/user-api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								server/test/db/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								server/test/db/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }; | ||||||
| @@ -1,11 +1,13 @@ | |||||||
| import { LoginResponseDto } from '@app/domain'; | import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; | ||||||
| import { AlbumController, AppModule } from '@app/immich'; | 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 { SharedLinkType } from '@app/infra/entities'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 request from 'supertest'; | ||||||
| import { errorStub, uuidStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| const user1SharedUser = 'user1SharedUser'; | const user1SharedUser = 'user1SharedUser'; | ||||||
| const user1SharedLink = 'user1SharedLink'; | const user1SharedLink = 'user1SharedLink'; | ||||||
| @@ -18,7 +20,10 @@ describe(`${AlbumController.name} (e2e)`, () => { | |||||||
|   let app: INestApplication; |   let app: INestApplication; | ||||||
|   let server: any; |   let server: any; | ||||||
|   let user1: LoginResponseDto; |   let user1: LoginResponseDto; | ||||||
|  |   let user1Asset: AssetFileUploadResponseDto; | ||||||
|  |   let user1Albums: AlbumResponseDto[]; | ||||||
|   let user2: LoginResponseDto; |   let user2: LoginResponseDto; | ||||||
|  |   let user2Albums: AlbumResponseDto[]; | ||||||
|  |  | ||||||
|   beforeAll(async () => { |   beforeAll(async () => { | ||||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
| @@ -31,8 +36,8 @@ describe(`${AlbumController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     const admin = await api.adminLogin(server); |     const admin = await api.authApi.adminLogin(server); | ||||||
|  |  | ||||||
|     await api.userApi.create(server, admin.accessToken, { |     await api.userApi.create(server, admin.accessToken, { | ||||||
|       email: 'user1@immich.app', |       email: 'user1@immich.app', | ||||||
| @@ -40,7 +45,7 @@ describe(`${AlbumController.name} (e2e)`, () => { | |||||||
|       firstName: 'User 1', |       firstName: 'User 1', | ||||||
|       lastName: 'Test', |       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, { |     await api.userApi.create(server, admin.accessToken, { | ||||||
|       email: 'user2@immich.app', |       email: 'user2@immich.app', | ||||||
| @@ -48,15 +53,17 @@ describe(`${AlbumController.name} (e2e)`, () => { | |||||||
|       firstName: 'User 2', |       firstName: 'User 2', | ||||||
|       lastName: 'Test', |       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, { |       api.albumApi.create(server, user1.accessToken, { | ||||||
|         albumName: user1SharedUser, |         albumName: user1SharedUser, | ||||||
|         sharedWithUserIds: [user2.userId], |         sharedWithUserIds: [user2.userId], | ||||||
|  |         assetIds: [user1Asset.id], | ||||||
|       }), |       }), | ||||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink }), |       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), | ||||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared }), |       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|     // add shared link to user1SharedLink album |     // add shared link to user1SharedLink album | ||||||
| @@ -65,10 +72,11 @@ describe(`${AlbumController.name} (e2e)`, () => { | |||||||
|       albumId: user1Albums[1].id, |       albumId: user1Albums[1].id, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const user2Albums = await Promise.all([ |     user2Albums = await Promise.all([ | ||||||
|       api.albumApi.create(server, user2.accessToken, { |       api.albumApi.create(server, user2.accessToken, { | ||||||
|         albumName: user2SharedUser, |         albumName: user2SharedUser, | ||||||
|         sharedWithUserIds: [user1.userId], |         sharedWithUserIds: [user1.userId], | ||||||
|  |         assetIds: [user1Asset.id], | ||||||
|       }), |       }), | ||||||
|       api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), |       api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), | ||||||
|       api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), |       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 () => { |     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) |       const { status, body } = await request(server) | ||||||
|         .get('/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790') |         .get(`/album?assetId=${asset.id}`) | ||||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|       expect(status).toEqual(200); |       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 () => { |     it('should return the album collection filtered by assetId and ignores shared=true', async () => { | ||||||
|       const { status, body } = await request(server) |       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}`); |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|       expect(status).toEqual(200); |       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 () => { |     it('should return the album collection filtered by assetId and ignores shared=false', async () => { | ||||||
|       const { status, body } = await request(server) |       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}`); |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|       expect(status).toEqual(200); |       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', () => { |   describe('PATCH /album/:id', () => { | ||||||
|     it('should require authentication', async () => { |     it('should require authentication', async () => { | ||||||
|       const { status, body } = await request(server) |       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' }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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 { AppModule, AssetController } from '@app/immich'; | ||||||
| import { AssetEntity, AssetType } from '@app/infra/entities'; | import { AssetEntity, AssetType } from '@app/infra/entities'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 { randomBytes } from 'crypto'; | ||||||
| import request from 'supertest'; | import request from 'supertest'; | ||||||
| import { errorStub, uuidStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| const user1Dto = { | const user1Dto = { | ||||||
|   email: 'user1@immich.app', |   email: 'user1@immich.app', | ||||||
| @@ -22,8 +23,30 @@ const user2Dto = { | |||||||
|   lastName: 'Test', |   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; | let assetCount = 0; | ||||||
| const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseDto): Promise<AssetEntity> => { | const createAsset = ( | ||||||
|  |   repository: IAssetRepository, | ||||||
|  |   loginResponse: LoginResponseDto, | ||||||
|  |   createdAt: Date, | ||||||
|  | ): Promise<AssetEntity> => { | ||||||
|   const id = assetCount++; |   const id = assetCount++; | ||||||
|   return repository.save({ |   return repository.save({ | ||||||
|     ownerId: loginResponse.userId, |     ownerId: loginResponse.userId, | ||||||
| @@ -31,7 +54,7 @@ const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseD | |||||||
|     originalPath: `/tests/test_${id}`, |     originalPath: `/tests/test_${id}`, | ||||||
|     deviceAssetId: `test_${id}`, |     deviceAssetId: `test_${id}`, | ||||||
|     deviceId: 'e2e-test', |     deviceId: 'e2e-test', | ||||||
|     fileCreatedAt: new Date(), |     fileCreatedAt: createdAt, | ||||||
|     fileModifiedAt: new Date(), |     fileModifiedAt: new Date(), | ||||||
|     type: AssetType.IMAGE, |     type: AssetType.IMAGE, | ||||||
|     originalFileName: `test_${id}`, |     originalFileName: `test_${id}`, | ||||||
| @@ -46,6 +69,8 @@ describe(`${AssetController.name} (e2e)`, () => { | |||||||
|   let user2: LoginResponseDto; |   let user2: LoginResponseDto; | ||||||
|   let asset1: AssetEntity; |   let asset1: AssetEntity; | ||||||
|   let asset2: AssetEntity; |   let asset2: AssetEntity; | ||||||
|  |   let asset3: AssetEntity; | ||||||
|  |   let asset4: AssetEntity; | ||||||
|  |  | ||||||
|   beforeAll(async () => { |   beforeAll(async () => { | ||||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
| @@ -59,16 +84,18 @@ describe(`${AssetController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     const admin = await api.adminLogin(server); |     const admin = await api.authApi.adminLogin(server); | ||||||
|  |  | ||||||
|     await api.userApi.create(server, admin.accessToken, user1Dto); |     await api.userApi.create(server, admin.accessToken, user1Dto); | ||||||
|     user1 = await api.login(server, { email: user1Dto.email, password: user1Dto.password }); |     user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); | ||||||
|     asset1 = await createAsset(assetRepository, user1); |     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); |     await api.userApi.create(server, admin.accessToken, user2Dto); | ||||||
|     user2 = await api.login(server, { email: user2Dto.email, password: user2Dto.password }); |     user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }); | ||||||
|     asset2 = await createAsset(assetRepository, user2); |     asset4 = await createAsset(assetRepository, user2, new Date('1970-01-01')); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   afterAll(async () => { |   afterAll(async () => { | ||||||
| @@ -76,6 +103,83 @@ describe(`${AssetController.name} (e2e)`, () => { | |||||||
|     await app.close(); |     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', () => { |   describe('PUT /asset/:id', () => { | ||||||
|     it('should require authentication', async () => { |     it('should require authentication', async () => { | ||||||
|       const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`); |       const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`); | ||||||
| @@ -93,7 +197,7 @@ describe(`${AssetController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|     it('should require access', async () => { |     it('should require access', async () => { | ||||||
|       const { status, body } = await request(server) |       const { status, body } = await request(server) | ||||||
|         .put(`/asset/${asset2.id}`) |         .put(`/asset/${asset4.id}`) | ||||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); |         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||||
|       expect(status).toBe(400); |       expect(status).toBe(400); | ||||||
|       expect(body).toEqual(errorStub.noPermission); |       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 }), | ||||||
|  |         ]), | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import { AppModule, AuthController } from '@app/immich'; | import { AppModule, AuthController } from '@app/immich'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | import { Test, TestingModule } from '@nestjs/testing'; | ||||||
| import request from 'supertest'; | import { api } from '@test/api'; | ||||||
|  | import { db } from '@test/db'; | ||||||
| import { | import { | ||||||
|   adminSignupStub, |   adminSignupStub, | ||||||
|   changePasswordStub, |   changePasswordStub, | ||||||
| @@ -11,8 +12,8 @@ import { | |||||||
|   loginStub, |   loginStub, | ||||||
|   signupResponseStub, |   signupResponseStub, | ||||||
|   uuidStub, |   uuidStub, | ||||||
| } from '../fixtures'; | } from '@test/fixtures'; | ||||||
| import { api, db } from '../test-utils'; | import request from 'supertest'; | ||||||
|  |  | ||||||
| const firstName = 'Immich'; | const firstName = 'Immich'; | ||||||
| const lastName = 'Admin'; | const lastName = 'Admin'; | ||||||
| @@ -35,8 +36,8 @@ describe(`${AuthController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     const response = await api.adminLogin(server); |     const response = await api.authApi.adminLogin(server); | ||||||
|     accessToken = response.accessToken; |     accessToken = response.accessToken; | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -67,7 +68,7 @@ describe(`${AuthController.name} (e2e)`, () => { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     it(`should sign up the admin`, async () => { |     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 () => { |     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 () => { |     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); |       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 () => { |     it('should require authentication', async () => { | ||||||
|       const { status, body } = await request(server).delete(`/auth/devices`); |       const { status, body } = await request(server).delete(`/auth/devices`); | ||||||
|       expect(status).toBe(401); |       expect(status).toBe(401); | ||||||
| @@ -161,15 +162,15 @@ describe(`${AuthController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|     it('should logout all devices (except the current one)', async () => { |     it('should logout all devices (except the current one)', async () => { | ||||||
|       for (let i = 0; i < 5; i++) { |       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}`); |       const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`); | ||||||
|       expect(status).toBe(204); |       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 () => { |     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) |       const { status } = await request(server) | ||||||
|         .delete(`/auth/devices/${device.id}`) |         .delete(`/auth/devices/${device.id}`) | ||||||
|         .set('Authorization', `Bearer ${accessToken}`); |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
| @@ -244,7 +245,7 @@ describe(`${AuthController.name} (e2e)`, () => { | |||||||
|         .set('Authorization', `Bearer ${accessToken}`); |         .set('Authorization', `Bearer ${accessToken}`); | ||||||
|       expect(status).toBe(200); |       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' }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
| import { AppModule, OAuthController } from '@app/immich'; | import { AppModule, OAuthController } from '@app/immich'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 request from 'supertest'; | ||||||
| import { errorStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| describe(`${OAuthController.name} (e2e)`, () => { | describe(`${OAuthController.name} (e2e)`, () => { | ||||||
|   let app: INestApplication; |   let app: INestApplication; | ||||||
| @@ -20,7 +21,7 @@ describe(`${OAuthController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   afterAll(async () => { |   afterAll(async () => { | ||||||
|   | |||||||
							
								
								
									
										146
									
								
								server/test/e2e/partner.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								server/test/e2e/partner.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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' })); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -1,16 +1,22 @@ | |||||||
| import { IPersonRepository, LoginResponseDto } from '@app/domain'; | import { IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain'; | ||||||
| import { AppModule, PersonController } from '@app/immich'; | import { AppModule, PersonController } from '@app/immich'; | ||||||
|  | import { PersonEntity } from '@app/infra/entities'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 request from 'supertest'; | ||||||
| import { errorStub, uuidStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| describe(`${PersonController.name}`, () => { | describe(`${PersonController.name}`, () => { | ||||||
|   let app: INestApplication; |   let app: INestApplication; | ||||||
|   let server: any; |   let server: any; | ||||||
|   let loginResponse: LoginResponseDto; |   let loginResponse: LoginResponseDto; | ||||||
|   let accessToken: string; |   let accessToken: string; | ||||||
|  |   let personRepository: IPersonRepository; | ||||||
|  |   let faceRepository: IFaceRepository; | ||||||
|  |   let visiblePerson: PersonEntity; | ||||||
|  |   let hiddenPerson: PersonEntity; | ||||||
|  |  | ||||||
|   beforeAll(async () => { |   beforeAll(async () => { | ||||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ |     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||||
| @@ -19,13 +25,31 @@ describe(`${PersonController.name}`, () => { | |||||||
|  |  | ||||||
|     app = await moduleFixture.createNestApplication().init(); |     app = await moduleFixture.createNestApplication().init(); | ||||||
|     server = app.getHttpServer(); |     server = app.getHttpServer(); | ||||||
|  |     personRepository = app.get<IPersonRepository>(IPersonRepository); | ||||||
|  |     faceRepository = app.get<IFaceRepository>(IFaceRepository); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     loginResponse = await api.adminLogin(server); |     loginResponse = await api.authApi.adminLogin(server); | ||||||
|     accessToken = loginResponse.accessToken; |     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 () => { |   afterAll(async () => { | ||||||
| @@ -33,6 +57,72 @@ describe(`${PersonController.name}`, () => { | |||||||
|     await app.close(); |     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', () => { |   describe('PUT /person/:id', () => { | ||||||
|     it('should require authentication', async () => { |     it('should require authentication', async () => { | ||||||
|       const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`); |       const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`); | ||||||
| @@ -42,10 +132,8 @@ describe(`${PersonController.name}`, () => { | |||||||
|  |  | ||||||
|     for (const key of ['name', 'featureFaceAssetId', 'isHidden']) { |     for (const key of ['name', 'featureFaceAssetId', 'isHidden']) { | ||||||
|       it(`should not allow null ${key}`, async () => { |       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) |         const { status, body } = await request(server) | ||||||
|           .put(`/person/${person.id}`) |           .put(`/person/${visiblePerson.id}`) | ||||||
|           .set('Authorization', `Bearer ${accessToken}`) |           .set('Authorization', `Bearer ${accessToken}`) | ||||||
|           .send({ [key]: null }); |           .send({ [key]: null }); | ||||||
|         expect(status).toBe(400); |         expect(status).toBe(400); | ||||||
| @@ -65,10 +153,8 @@ describe(`${PersonController.name}`, () => { | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     it('should update a date of birth', async () => { |     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) |       const { status, body } = await request(server) | ||||||
|         .put(`/person/${person.id}`) |         .put(`/person/${visiblePerson.id}`) | ||||||
|         .set('Authorization', `Bearer ${accessToken}`) |         .set('Authorization', `Bearer ${accessToken}`) | ||||||
|         .send({ birthDate: '1990-01-01T05:00:00.000Z' }); |         .send({ birthDate: '1990-01-01T05:00:00.000Z' }); | ||||||
|       expect(status).toBe(200); |       expect(status).toBe(200); | ||||||
| @@ -76,7 +162,6 @@ describe(`${PersonController.name}`, () => { | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     it('should clear a date of birth', async () => { |     it('should clear a date of birth', async () => { | ||||||
|       const personRepository = app.get<IPersonRepository>(IPersonRepository); |  | ||||||
|       const person = await personRepository.create({ |       const person = await personRepository.create({ | ||||||
|         birthDate: new Date('1990-01-01'), |         birthDate: new Date('1990-01-01'), | ||||||
|         ownerId: loginResponse.userId, |         ownerId: loginResponse.userId, | ||||||
|   | |||||||
| @@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain'; | |||||||
| import { AppModule, ServerInfoController } from '@app/immich'; | import { AppModule, ServerInfoController } from '@app/immich'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 request from 'supertest'; | ||||||
| import { errorStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| describe(`${ServerInfoController.name} (e2e)`, () => { | describe(`${ServerInfoController.name} (e2e)`, () => { | ||||||
|   let app: INestApplication; |   let app: INestApplication; | ||||||
| @@ -23,8 +24,8 @@ describe(`${ServerInfoController.name} (e2e)`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     loginResponse = await api.adminLogin(server); |     loginResponse = await api.authApi.adminLogin(server); | ||||||
|     accessToken = loginResponse.accessToken; |     accessToken = loginResponse.accessToken; | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -116,7 +117,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { | |||||||
|     it('should only work for admins', async () => { |     it('should only work for admins', async () => { | ||||||
|       const loginDto = { email: 'test@immich.app', password: 'Immich123' }; |       const loginDto = { email: 'test@immich.app', password: 'Immich123' }; | ||||||
|       await api.userApi.create(server, accessToken, { ...loginDto, firstName: 'test', lastName: 'test' }); |       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) |       const { status, body } = await request(server) | ||||||
|         .get('/server-info/stats') |         .get('/server-info/stats') | ||||||
|         .set('Authorization', `Bearer ${userAccessToken}`); |         .set('Authorization', `Bearer ${userAccessToken}`); | ||||||
|   | |||||||
							
								
								
									
										241
									
								
								server/test/e2e/shared-link.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								server/test/e2e/shared-link.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain'; | |||||||
| import { AppModule, UserController } from '@app/immich'; | import { AppModule, UserController } from '@app/immich'; | ||||||
| import { INestApplication } from '@nestjs/common'; | import { INestApplication } from '@nestjs/common'; | ||||||
| import { Test, TestingModule } from '@nestjs/testing'; | 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 request from 'supertest'; | ||||||
| import { errorStub, userSignupStub, userStub } from '../fixtures'; |  | ||||||
| import { api, db } from '../test-utils'; |  | ||||||
|  |  | ||||||
| describe(`${UserController.name}`, () => { | describe(`${UserController.name}`, () => { | ||||||
|   let app: INestApplication; |   let app: INestApplication; | ||||||
| @@ -23,8 +24,8 @@ describe(`${UserController.name}`, () => { | |||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     await api.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     loginResponse = await api.adminLogin(server); |     loginResponse = await api.authApi.adminLogin(server); | ||||||
|     accessToken = loginResponse.accessToken; |     accessToken = loginResponse.accessToken; | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								server/test/fixtures/search.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								server/test/fixtures/search.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,4 +1,6 @@ | |||||||
| import { SearchResult } from '@app/domain'; | import { SearchResult } from '@app/domain'; | ||||||
|  | import { AssetEntity } from '@app/infra/entities'; | ||||||
|  | import { assetStub } from '.'; | ||||||
|  |  | ||||||
| export const searchStub = { | export const searchStub = { | ||||||
|   emptyResults: Object.freeze<SearchResult<any>>({ |   emptyResults: Object.freeze<SearchResult<any>>({ | ||||||
| @@ -9,4 +11,13 @@ export const searchStub = { | |||||||
|     facets: [], |     facets: [], | ||||||
|     distances: [], |     distances: [], | ||||||
|   }), |   }), | ||||||
|  |  | ||||||
|  |   withImage: Object.freeze<SearchResult<AssetEntity>>({ | ||||||
|  |     total: 1, | ||||||
|  |     count: 1, | ||||||
|  |     page: 1, | ||||||
|  |     items: [assetStub.image], | ||||||
|  |     facets: [], | ||||||
|  |     distances: [], | ||||||
|  |   }), | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -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; |  | ||||||
		Reference in New Issue
	
	Block a user