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', () => { | ||||
|     it('creates album', async () => { | ||||
|       albumMock.create.mockResolvedValue(albumStub.empty); | ||||
|       userMock.get.mockResolvedValue(userStub.user1); | ||||
|  | ||||
|       await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ | ||||
|       await sut.create(authStub.admin, { | ||||
|         albumName: 'Empty album', | ||||
|         sharedWithUserIds: ['user-id'], | ||||
|         description: '', | ||||
|         albumThumbnailAssetId: null, | ||||
|         assetCount: 0, | ||||
|         assets: [], | ||||
|         createdAt: expect.anything(), | ||||
|         id: 'album-1', | ||||
|         owner: { | ||||
|           email: 'admin@test.com', | ||||
|           firstName: 'admin_first_name', | ||||
|           id: 'admin_id', | ||||
|           isAdmin: true, | ||||
|           lastName: 'admin_last_name', | ||||
|           oauthId: '', | ||||
|           profileImagePath: '', | ||||
|           shouldChangePassword: false, | ||||
|           storageLabel: 'admin', | ||||
|           createdAt: new Date('2021-01-01'), | ||||
|           deletedAt: null, | ||||
|           updatedAt: new Date('2021-01-01'), | ||||
|           externalPath: null, | ||||
|           memoriesEnabled: true, | ||||
|         }, | ||||
|         ownerId: 'admin_id', | ||||
|         shared: false, | ||||
|         sharedUsers: [], | ||||
|         startDate: undefined, | ||||
|         endDate: undefined, | ||||
|         hasSharedLink: false, | ||||
|         updatedAt: expect.anything(), | ||||
|         assetIds: ['123'], | ||||
|       }); | ||||
|  | ||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||
|         name: JobName.SEARCH_INDEX_ALBUM, | ||||
|         data: { ids: [albumStub.empty.id] }, | ||||
|       }); | ||||
|  | ||||
|       expect(albumMock.create).toHaveBeenCalledWith({ | ||||
|         ownerId: authStub.admin.id, | ||||
|         albumName: albumStub.empty.albumName, | ||||
|         description: albumStub.empty.description, | ||||
|         sharedUsers: [{ id: 'user-id' }], | ||||
|         assets: [{ id: '123' }], | ||||
|         albumThumbnailAssetId: '123', | ||||
|       }); | ||||
|  | ||||
|       expect(userMock.get).toHaveBeenCalledWith('user-id'); | ||||
|     }); | ||||
|  | ||||
|     it('should require valid userIds', async () => { | ||||
|   | ||||
| @@ -136,9 +136,6 @@ export class AlbumService { | ||||
|     await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); | ||||
|  | ||||
|     const album = await this.findOrFail(id, { withAssets: false }); | ||||
|     if (!album) { | ||||
|       throw new BadRequestException('Album not found'); | ||||
|     } | ||||
|  | ||||
|     await this.albumRepository.delete(album); | ||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); | ||||
| @@ -228,6 +225,10 @@ export class AlbumService { | ||||
|     const album = await this.findOrFail(id, { withAssets: false }); | ||||
|  | ||||
|     for (const userId of dto.sharedUserIds) { | ||||
|       if (album.ownerId === userId) { | ||||
|         throw new BadRequestException('Cannot be shared with owner'); | ||||
|       } | ||||
|  | ||||
|       const exists = album.sharedUsers.find((user) => user.id === userId); | ||||
|       if (exists) { | ||||
|         throw new BadRequestException('User already added'); | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import { Readable } from 'stream'; | ||||
| import { ICryptoRepository } from '../crypto'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { IStorageRepository } from '../storage'; | ||||
| import { AssetStats, IAssetRepository } from './asset.repository'; | ||||
| import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository'; | ||||
| import { AssetService, UploadFieldName } from './asset.service'; | ||||
| import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; | ||||
| import { mapAsset } from './response-dto'; | ||||
| @@ -330,6 +330,73 @@ describe(AssetService.name, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getTimeBuckets', () => { | ||||
|     it("should return buckets if userId and albumId aren't set", async () => { | ||||
|       assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); | ||||
|  | ||||
|       await expect( | ||||
|         sut.getTimeBuckets(authStub.admin, { | ||||
|           size: TimeBucketSize.DAY, | ||||
|         }), | ||||
|       ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); | ||||
|       expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userId: authStub.admin.id }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getByTimeBucket', () => { | ||||
|     it('should return the assets for a album time bucket if user has album.read', async () => { | ||||
|       accessMock.album.hasOwnerAccess.mockResolvedValue(true); | ||||
|       assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); | ||||
|  | ||||
|       await expect( | ||||
|         sut.getByTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), | ||||
|       ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); | ||||
|  | ||||
|       expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-id'); | ||||
|       expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { | ||||
|         size: TimeBucketSize.DAY, | ||||
|         timeBucket: 'bucket', | ||||
|         albumId: 'album-id', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should return the assets for a archive time bucket if user has archive.read', async () => { | ||||
|       assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); | ||||
|  | ||||
|       await expect( | ||||
|         sut.getByTimeBucket(authStub.admin, { | ||||
|           size: TimeBucketSize.DAY, | ||||
|           timeBucket: 'bucket', | ||||
|           isArchived: true, | ||||
|           userId: authStub.admin.id, | ||||
|         }), | ||||
|       ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); | ||||
|       expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { | ||||
|         size: TimeBucketSize.DAY, | ||||
|         timeBucket: 'bucket', | ||||
|         isArchived: true, | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should return the assets for a library time bucket if user has library.read', async () => { | ||||
|       assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); | ||||
|  | ||||
|       await expect( | ||||
|         sut.getByTimeBucket(authStub.admin, { | ||||
|           size: TimeBucketSize.DAY, | ||||
|           timeBucket: 'bucket', | ||||
|           userId: authStub.admin.id, | ||||
|         }), | ||||
|       ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); | ||||
|       expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { | ||||
|         size: TimeBucketSize.DAY, | ||||
|         timeBucket: 'bucket', | ||||
|         userId: authStub.admin.id, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('downloadFile', () => { | ||||
|     it('should require the asset.download permission', async () => { | ||||
|       accessMock.asset.hasOwnerAccess.mockResolvedValue(false); | ||||
|   | ||||
| @@ -214,6 +214,15 @@ describe('AuthService', () => { | ||||
|  | ||||
|       expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123'); | ||||
|     }); | ||||
|  | ||||
|     it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { | ||||
|       const authUser = { id: '123' } as AuthUserDto; | ||||
|  | ||||
|       await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({ | ||||
|         successful: true, | ||||
|         redirectUri: '/auth/login?autoLaunch=0', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('adminSignUp', () => { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Colorspace } from '@app/infra/entities'; | ||||
| import { Colorspace, SystemConfigKey } from '@app/infra/entities'; | ||||
| import { | ||||
|   assetStub, | ||||
|   faceStub, | ||||
| @@ -137,6 +137,14 @@ describe(FacialRecognitionService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleQueueRecognizeFaces', () => { | ||||
|     it('should return if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); | ||||
|       expect(jobMock.queue).not.toHaveBeenCalled(); | ||||
|       expect(configMock.load).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should queue missing assets', async () => { | ||||
|       assetMock.getWithout.mockResolvedValue({ | ||||
|         items: [assetStub.image], | ||||
| @@ -170,6 +178,14 @@ describe(FacialRecognitionService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleRecognizeFaces', () => { | ||||
|     it('should return if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await expect(sut.handleRecognizeFaces({ id: 'foo' })).resolves.toBe(true); | ||||
|       expect(assetMock.getByIds).not.toHaveBeenCalled(); | ||||
|       expect(configMock.load).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip when no resize path', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); | ||||
|       await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id }); | ||||
| @@ -260,6 +276,14 @@ describe(FacialRecognitionService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleGenerateFaceThumbnail', () => { | ||||
|     it('should return if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await expect(sut.handleGenerateFaceThumbnail(face.middle)).resolves.toBe(true); | ||||
|       expect(assetMock.getByIds).not.toHaveBeenCalled(); | ||||
|       expect(configMock.load).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip an asset not found', async () => { | ||||
|       assetMock.getByIds.mockResolvedValue([]); | ||||
|  | ||||
|   | ||||
| @@ -288,6 +288,17 @@ describe(JobService.name, () => { | ||||
|           JobName.VIDEO_CONVERSION, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-live-image', source: 'upload' } }, | ||||
|         jobs: [ | ||||
|           JobName.CLASSIFY_IMAGE, | ||||
|           JobName.GENERATE_WEBP_THUMBNAIL, | ||||
|           JobName.RECOGNIZE_FACES, | ||||
|           JobName.GENERATE_THUMBHASH_THUMBNAIL, | ||||
|           JobName.ENCODE_CLIP, | ||||
|           JobName.VIDEO_CONVERSION, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } }, | ||||
|         jobs: [JobName.SEARCH_INDEX_ASSET], | ||||
| @@ -305,7 +316,11 @@ describe(JobService.name, () => { | ||||
|     for (const { item, jobs } of tests) { | ||||
|       it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { | ||||
|         if (item.name === JobName.GENERATE_JPEG_THUMBNAIL && item.data.source === 'upload') { | ||||
|           assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); | ||||
|           if (item.data.id === 'asset-live-image') { | ||||
|             assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); | ||||
|           } else { | ||||
|             assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); | ||||
|           } | ||||
|         } else { | ||||
|           assetMock.getByIds.mockResolvedValue([]); | ||||
|         } | ||||
|   | ||||
| @@ -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', () => { | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { | ||||
|   albumStub, | ||||
|   assetStub, | ||||
| @@ -15,12 +16,13 @@ import { | ||||
| } from '@test'; | ||||
| import { plainToInstance } from 'class-transformer'; | ||||
| import { IAlbumRepository } from '../album/album.repository'; | ||||
| import { mapAsset } from '../asset'; | ||||
| import { IAssetRepository } from '../asset/asset.repository'; | ||||
| import { IFaceRepository } from '../facial-recognition'; | ||||
| import { ISystemConfigRepository } from '../index'; | ||||
| import { JobName } from '../job'; | ||||
| import { IJobRepository } from '../job/job.repository'; | ||||
| import { IMachineLearningRepository } from '../smart-info'; | ||||
| import { ISystemConfigRepository } from '../system-config'; | ||||
| import { SearchDto } from './dto'; | ||||
| import { ISearchRepository } from './search.repository'; | ||||
| import { SearchService } from './search.service'; | ||||
| @@ -50,9 +52,17 @@ describe(SearchService.name, () => { | ||||
|  | ||||
|     searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); | ||||
|  | ||||
|     delete process.env.TYPESENSE_ENABLED; | ||||
|     await sut.init(); | ||||
|   }); | ||||
|  | ||||
|   const disableSearch = () => { | ||||
|     searchMock.setup.mockClear(); | ||||
|     searchMock.checkMigrationStatus.mockClear(); | ||||
|     jobMock.queue.mockClear(); | ||||
|     process.env.TYPESENSE_ENABLED = 'false'; | ||||
|   }; | ||||
|  | ||||
|   afterEach(() => { | ||||
|     sut.teardown(); | ||||
|   }); | ||||
| @@ -84,15 +94,14 @@ describe(SearchService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe(`init`, () => { | ||||
|     // it('should skip when search is disabled', async () => { | ||||
|     //   await sut.init(); | ||||
|     it('should skip when search is disabled', async () => { | ||||
|       disableSearch(); | ||||
|       await sut.init(); | ||||
|  | ||||
|     //   expect(searchMock.setup).not.toHaveBeenCalled(); | ||||
|     //   expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); | ||||
|     //   expect(jobMock.queue).not.toHaveBeenCalled(); | ||||
|  | ||||
|     //   sut.teardown(); | ||||
|     // }); | ||||
|       expect(searchMock.setup).not.toHaveBeenCalled(); | ||||
|       expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); | ||||
|       expect(jobMock.queue).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip schema migration if not needed', async () => { | ||||
|       await sut.init(); | ||||
| @@ -114,6 +123,29 @@ describe(SearchService.name, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getExploreData', () => { | ||||
|     it('should throw bad request exception if search is disabled', async () => { | ||||
|       disableSearch(); | ||||
|       await expect(sut.getExploreData(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(searchMock.explore).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should return explore data if feature flag SEARCH is set', async () => { | ||||
|       searchMock.explore.mockResolvedValue([{ fieldName: 'name', items: [{ value: 'image', data: assetStub.image }] }]); | ||||
|       assetMock.getByIds.mockResolvedValue([assetStub.image]); | ||||
|  | ||||
|       await expect(sut.getExploreData(authStub.admin)).resolves.toEqual([ | ||||
|         { | ||||
|           fieldName: 'name', | ||||
|           items: [{ value: 'image', data: mapAsset(assetStub.image) }], | ||||
|         }, | ||||
|       ]); | ||||
|  | ||||
|       expect(searchMock.explore).toHaveBeenCalledWith(authStub.admin.id); | ||||
|       expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('search', () => { | ||||
|     // it('should throw an error is search is disabled', async () => { | ||||
|     //   sut['enabled'] = false; | ||||
| @@ -124,12 +156,40 @@ describe(SearchService.name, () => { | ||||
|     //   expect(searchMock.searchAssets).not.toHaveBeenCalled(); | ||||
|     // }); | ||||
|  | ||||
|     it('should search assets and albums', async () => { | ||||
|       searchMock.searchAssets.mockResolvedValue(searchStub.emptyResults); | ||||
|     it('should search assets and albums using text search', async () => { | ||||
|       searchMock.searchAssets.mockResolvedValue(searchStub.withImage); | ||||
|       searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); | ||||
|       searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults); | ||||
|       assetMock.getByIds.mockResolvedValue([assetStub.image]); | ||||
|  | ||||
|       await expect(sut.search(authStub.admin, {})).resolves.toEqual({ | ||||
|         albums: { | ||||
|           total: 0, | ||||
|           count: 0, | ||||
|           page: 1, | ||||
|           items: [], | ||||
|           facets: [], | ||||
|           distances: [], | ||||
|         }, | ||||
|         assets: { | ||||
|           total: 1, | ||||
|           count: 1, | ||||
|           page: 1, | ||||
|           items: [mapAsset(assetStub.image)], | ||||
|           facets: [], | ||||
|           distances: [], | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); | ||||
|       expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); | ||||
|     }); | ||||
|  | ||||
|     it('should search assets and albums using vector search', async () => { | ||||
|       searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults); | ||||
|       searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); | ||||
|       machineMock.encodeText.mockResolvedValue([123]); | ||||
|  | ||||
|       await expect(sut.search(authStub.admin, { clip: true, query: 'foo' })).resolves.toEqual({ | ||||
|         albums: { | ||||
|           total: 0, | ||||
|           count: 0, | ||||
| @@ -148,8 +208,17 @@ describe(SearchService.name, () => { | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); | ||||
|       expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); | ||||
|       expect(machineMock.encodeText).toHaveBeenCalledWith(expect.any(String), { text: 'foo' }, expect.any(Object)); | ||||
|       expect(searchMock.vectorSearch).toHaveBeenCalledWith([123], { | ||||
|         userId: authStub.admin.id, | ||||
|         clip: true, | ||||
|         query: 'foo', | ||||
|       }); | ||||
|       expect(searchMock.searchAlbums).toHaveBeenCalledWith('foo', { | ||||
|         userId: authStub.admin.id, | ||||
|         clip: true, | ||||
|         query: 'foo', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { AssetEntity } from '@app/infra/entities'; | ||||
| import { AssetEntity, SystemConfigKey } from '@app/infra/entities'; | ||||
| import { | ||||
|   assetStub, | ||||
|   newAssetRepositoryMock, | ||||
| @@ -43,6 +43,15 @@ describe(SmartInfoService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleQueueObjectTagging', () => { | ||||
|     it('should do nothing if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await sut.handleQueueObjectTagging({}); | ||||
|  | ||||
|       expect(assetMock.getAll).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.getWithout).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should queue the assets without tags', async () => { | ||||
|       assetMock.getWithout.mockResolvedValue({ | ||||
|         items: [assetStub.image], | ||||
| @@ -68,7 +77,16 @@ describe(SmartInfoService.name, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('handleTagImage', () => { | ||||
|   describe('handleClassifyImage', () => { | ||||
|     it('should do nothing if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await sut.handleClassifyImage({ id: '123' }); | ||||
|  | ||||
|       expect(machineMock.classifyImage).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.getByIds).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip assets without a resize path', async () => { | ||||
|       const asset = { resizePath: '' } as AssetEntity; | ||||
|       assetMock.getByIds.mockResolvedValue([asset]); | ||||
| @@ -108,6 +126,15 @@ describe(SmartInfoService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleQueueEncodeClip', () => { | ||||
|     it('should do nothing if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await sut.handleQueueEncodeClip({}); | ||||
|  | ||||
|       expect(assetMock.getAll).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.getWithout).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should queue the assets without clip embeddings', async () => { | ||||
|       assetMock.getWithout.mockResolvedValue({ | ||||
|         items: [assetStub.image], | ||||
| @@ -134,6 +161,15 @@ describe(SmartInfoService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('handleEncodeClip', () => { | ||||
|     it('should do nothing if machine learning is disabled', async () => { | ||||
|       configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); | ||||
|  | ||||
|       await sut.handleEncodeClip({ id: '123' }); | ||||
|  | ||||
|       expect(assetMock.getByIds).not.toHaveBeenCalled(); | ||||
|       expect(machineMock.encodeImage).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should skip assets without a resize path', async () => { | ||||
|       const asset = { resizePath: '' } as AssetEntity; | ||||
|       assetMock.getByIds.mockResolvedValue([asset]); | ||||
|   | ||||
| @@ -34,6 +34,41 @@ describe(StorageTemplateService.name, () => { | ||||
|     sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock); | ||||
|   }); | ||||
|  | ||||
|   describe('handleMigrationSingle', () => { | ||||
|     it('should migrate single moving picture', async () => { | ||||
|       userMock.get.mockResolvedValue(userStub.user1); | ||||
|       const path = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}.jpg`; | ||||
|       const newPath = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}+1.jpg`; | ||||
|  | ||||
|       when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoStillAsset.id)).mockResolvedValue(true); | ||||
|       when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoStillAsset.id)).mockResolvedValue(false); | ||||
|  | ||||
|       when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(true); | ||||
|       when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(false); | ||||
|  | ||||
|       when(assetMock.save) | ||||
|         .calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newPath(assetStub.livePhotoStillAsset.id) }) | ||||
|         .mockResolvedValue(assetStub.livePhotoStillAsset); | ||||
|  | ||||
|       when(assetMock.save) | ||||
|         .calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newPath(assetStub.livePhotoMotionAsset.id) }) | ||||
|         .mockResolvedValue(assetStub.livePhotoMotionAsset); | ||||
|  | ||||
|       when(assetMock.getByIds) | ||||
|         .calledWith([assetStub.livePhotoStillAsset.id]) | ||||
|         .mockResolvedValue([assetStub.livePhotoStillAsset]); | ||||
|  | ||||
|       when(assetMock.getByIds) | ||||
|         .calledWith([assetStub.livePhotoMotionAsset.id]) | ||||
|         .mockResolvedValue([assetStub.livePhotoMotionAsset]); | ||||
|  | ||||
|       await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); | ||||
|  | ||||
|       expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); | ||||
|       expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('handle template migration', () => { | ||||
|     it('should handle no assets', async () => { | ||||
|       assetMock.getAll.mockResolvedValue({ | ||||
|   | ||||
| @@ -89,7 +89,6 @@ export class StorageTemplateService { | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   // TODO: use asset core (once in domain) | ||||
|   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { | ||||
|     if (asset.isReadOnly) { | ||||
|       this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); | ||||
| @@ -121,7 +120,7 @@ export class StorageTemplateService { | ||||
|             error?.stack, | ||||
|           ); | ||||
|  | ||||
|           // Either sidecar move failed or the save failed. Eithr way, move media back | ||||
|           // Either sidecar move failed or the save failed. Either way, move media back | ||||
|           await this.storageRepository.moveFile(destination, source); | ||||
|  | ||||
|           if (asset.sidecarPath && sidecarDestination && sidecarMoved) { | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| import { UserEntity } from '@app/infra/entities'; | ||||
| import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; | ||||
| import { | ||||
|   BadRequestException, | ||||
|   ForbiddenException, | ||||
|   InternalServerErrorException, | ||||
|   NotFoundException, | ||||
| } from '@nestjs/common'; | ||||
| import { | ||||
|   newAlbumRepositoryMock, | ||||
|   newAssetRepositoryMock, | ||||
| @@ -7,6 +12,7 @@ import { | ||||
|   newJobRepositoryMock, | ||||
|   newStorageRepositoryMock, | ||||
|   newUserRepositoryMock, | ||||
|   userStub, | ||||
| } from '@test'; | ||||
| import { when } from 'jest-when'; | ||||
| import { IAlbumRepository } from '../album'; | ||||
| @@ -16,7 +22,7 @@ import { ICryptoRepository } from '../crypto'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { IStorageRepository } from '../storage'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserResponseDto } from './response-dto'; | ||||
| import { UserResponseDto, mapUser } from './response-dto'; | ||||
| import { IUserRepository } from './user.repository'; | ||||
| import { UserService } from './user.service'; | ||||
|  | ||||
| @@ -216,6 +222,13 @@ describe(UserService.name, () => { | ||||
|       expect(userMock.getList).toHaveBeenCalled(); | ||||
|       expect(response).toEqual({ userCount: 1 }); | ||||
|     }); | ||||
|  | ||||
|     it('should get the user count of all admin users', async () => { | ||||
|       userMock.getList.mockResolvedValue([adminUser, immichUser]); | ||||
|  | ||||
|       await expect(sut.getCount({ admin: true })).resolves.toEqual({ userCount: 1 }); | ||||
|       expect(userMock.getList).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('update', () => { | ||||
| @@ -223,12 +236,17 @@ describe(UserService.name, () => { | ||||
|       const update: UpdateUserDto = { | ||||
|         id: immichUser.id, | ||||
|         shouldChangePassword: true, | ||||
|         email: 'immich@test.com', | ||||
|         storageLabel: 'storage_label', | ||||
|       }; | ||||
|       userMock.getByEmail.mockResolvedValue(null); | ||||
|       userMock.getByStorageLabel.mockResolvedValue(null); | ||||
|       userMock.update.mockResolvedValue({ ...updatedImmichUser, isAdmin: true, storageLabel: 'storage_label' }); | ||||
|  | ||||
|       when(userMock.update).calledWith(update.id, update).mockResolvedValueOnce(updatedImmichUser); | ||||
|  | ||||
|       const updatedUser = await sut.update(immichUserAuth, update); | ||||
|       const updatedUser = await sut.update({ ...immichUserAuth, isAdmin: true }, update); | ||||
|       expect(updatedUser.shouldChangePassword).toEqual(true); | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); | ||||
|       expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); | ||||
|     }); | ||||
|  | ||||
|     it('should not set an empty string for storage label', async () => { | ||||
| @@ -345,20 +363,37 @@ describe(UserService.name, () => { | ||||
|   }); | ||||
|  | ||||
|   describe('restore', () => { | ||||
|     it('should throw error if user could not be found', async () => { | ||||
|       userMock.get.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); | ||||
|       expect(userMock.restore).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should require an admin', async () => { | ||||
|       when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser); | ||||
|       await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||
|       expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true); | ||||
|     }); | ||||
|  | ||||
|     it('should require the auth user be an admin', async () => { | ||||
|       await expect(sut.delete(immichUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||
|     it('should restore an user', async () => { | ||||
|       userMock.get.mockResolvedValue(immichUser); | ||||
|       userMock.restore.mockResolvedValue(immichUser); | ||||
|  | ||||
|       expect(userMock.delete).not.toHaveBeenCalled(); | ||||
|       await expect(sut.restore(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); | ||||
|       expect(userMock.get).toHaveBeenCalledWith(immichUser.id, true); | ||||
|       expect(userMock.restore).toHaveBeenCalledWith(immichUser); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('delete', () => { | ||||
|     it('should throw error if user could not be found', async () => { | ||||
|       userMock.get.mockResolvedValue(null); | ||||
|  | ||||
|       await expect(sut.delete(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); | ||||
|       expect(userMock.delete).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('cannot delete admin user', async () => { | ||||
|       await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); | ||||
|     }); | ||||
| @@ -368,9 +403,18 @@ describe(UserService.name, () => { | ||||
|  | ||||
|       expect(userMock.delete).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should delete user', async () => { | ||||
|       userMock.get.mockResolvedValue(immichUser); | ||||
|       userMock.delete.mockResolvedValue(immichUser); | ||||
|  | ||||
|       await expect(sut.delete(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); | ||||
|       expect(userMock.get).toHaveBeenCalledWith(immichUser.id, undefined); | ||||
|       expect(userMock.delete).toHaveBeenCalledWith(immichUser); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('update', () => { | ||||
|   describe('create', () => { | ||||
|     it('should not create a user if there is no local admin account', async () => { | ||||
|       when(userMock.getAdmin).calledWith().mockResolvedValueOnce(null); | ||||
|  | ||||
| @@ -383,6 +427,30 @@ describe(UserService.name, () => { | ||||
|         }), | ||||
|       ).rejects.toBeInstanceOf(BadRequestException); | ||||
|     }); | ||||
|  | ||||
|     it('should create user', async () => { | ||||
|       userMock.getAdmin.mockResolvedValue(userStub.admin); | ||||
|       userMock.create.mockResolvedValue(userStub.user1); | ||||
|  | ||||
|       await expect( | ||||
|         sut.create({ | ||||
|           email: userStub.user1.email, | ||||
|           firstName: userStub.user1.firstName, | ||||
|           lastName: userStub.user1.lastName, | ||||
|           password: 'password', | ||||
|           storageLabel: 'label', | ||||
|         }), | ||||
|       ).resolves.toEqual(mapUser(userStub.user1)); | ||||
|  | ||||
|       expect(userMock.getAdmin).toBeCalled(); | ||||
|       expect(userMock.create).toBeCalledWith({ | ||||
|         email: userStub.user1.email, | ||||
|         firstName: userStub.user1.firstName, | ||||
|         lastName: userStub.user1.lastName, | ||||
|         storageLabel: 'label', | ||||
|         password: expect.anything(), | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('createProfileImage', () => { | ||||
| @@ -394,6 +462,13 @@ describe(UserService.name, () => { | ||||
|  | ||||
|       expect(userMock.update).toHaveBeenCalledWith(adminUserAuth.id, { profileImagePath: file.path }); | ||||
|     }); | ||||
|  | ||||
|     it('should throw an error if the user profile could not be updated with the new image', async () => { | ||||
|       const file = { path: '/profile/path' } as Express.Multer.File; | ||||
|       userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); | ||||
|  | ||||
|       await expect(sut.createProfileImage(adminUserAuth, file)).rejects.toThrowError(InternalServerErrorException); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getUserProfileImage', () => { | ||||
|   | ||||
| @@ -63,7 +63,7 @@ export class AssetController { | ||||
|   async uploadFile( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|     @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, | ||||
|     @Body(new ValidationPipe()) dto: CreateAssetDto, | ||||
|     @Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto, | ||||
|     @Response({ passthrough: true }) res: Res, | ||||
|   ): Promise<AssetFileUploadResponseDto> { | ||||
|     const file = mapToUploadFile(files.assetData[0]); | ||||
| @@ -90,7 +90,7 @@ export class AssetController { | ||||
|   @Post('import') | ||||
|   async importFile( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|     @Body(new ValidationPipe()) dto: ImportAssetDto, | ||||
|     @Body(new ValidationPipe({ transform: true })) dto: ImportAssetDto, | ||||
|     @Response({ passthrough: true }) res: Res, | ||||
|   ): Promise<AssetFileUploadResponseDto> { | ||||
|     const responseDto = await this.assetService.importFile(authUser, dto); | ||||
|   | ||||
| @@ -1,33 +1,43 @@ | ||||
| import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain'; | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; | ||||
| import { Transform, Type } from 'class-transformer'; | ||||
| import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator'; | ||||
|  | ||||
| export class CreateAssetBase { | ||||
|   @IsNotEmpty() | ||||
|   @IsString() | ||||
|   deviceAssetId!: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   @IsString() | ||||
|   deviceId!: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   @IsDate() | ||||
|   @Type(() => Date) | ||||
|   fileCreatedAt!: Date; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   @IsDate() | ||||
|   @Type(() => Date) | ||||
|   fileModifiedAt!: Date; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isFavorite!: boolean; | ||||
|  | ||||
|   @Optional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isArchived?: boolean; | ||||
|  | ||||
|   @Optional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isVisible?: boolean; | ||||
|  | ||||
|   @Optional() | ||||
|   @IsString() | ||||
|   duration?: string; | ||||
| } | ||||
|  | ||||
| @@ -51,6 +61,7 @@ export class CreateAssetDto extends CreateAssetBase { | ||||
|  | ||||
| export class ImportAssetDto extends CreateAssetBase { | ||||
|   @Optional() | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   isReadOnly?: boolean = true; | ||||
|  | ||||
|   | ||||
							
								
								
									
										18
									
								
								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 { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; | ||||
| import { SharedLinkType } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub, uuidStub } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub, uuidStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| const user1SharedUser = 'user1SharedUser'; | ||||
| const user1SharedLink = 'user1SharedLink'; | ||||
| @@ -18,7 +20,10 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|   let app: INestApplication; | ||||
|   let server: any; | ||||
|   let user1: LoginResponseDto; | ||||
|   let user1Asset: AssetFileUploadResponseDto; | ||||
|   let user1Albums: AlbumResponseDto[]; | ||||
|   let user2: LoginResponseDto; | ||||
|   let user2Albums: AlbumResponseDto[]; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||
| @@ -31,8 +36,8 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     const admin = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     const admin = await api.authApi.adminLogin(server); | ||||
|  | ||||
|     await api.userApi.create(server, admin.accessToken, { | ||||
|       email: 'user1@immich.app', | ||||
| @@ -40,7 +45,7 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       firstName: 'User 1', | ||||
|       lastName: 'Test', | ||||
|     }); | ||||
|     user1 = await api.login(server, { email: 'user1@immich.app', password: 'Password123' }); | ||||
|     user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); | ||||
|  | ||||
|     await api.userApi.create(server, admin.accessToken, { | ||||
|       email: 'user2@immich.app', | ||||
| @@ -48,15 +53,17 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       firstName: 'User 2', | ||||
|       lastName: 'Test', | ||||
|     }); | ||||
|     user2 = await api.login(server, { email: 'user2@immich.app', password: 'Password123' }); | ||||
|     user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }); | ||||
|  | ||||
|     const user1Albums = await Promise.all([ | ||||
|     user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example'); | ||||
|     user1Albums = await Promise.all([ | ||||
|       api.albumApi.create(server, user1.accessToken, { | ||||
|         albumName: user1SharedUser, | ||||
|         sharedWithUserIds: [user2.userId], | ||||
|         assetIds: [user1Asset.id], | ||||
|       }), | ||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink }), | ||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared }), | ||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), | ||||
|       api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), | ||||
|     ]); | ||||
|  | ||||
|     // add shared link to user1SharedLink album | ||||
| @@ -65,10 +72,11 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       albumId: user1Albums[1].id, | ||||
|     }); | ||||
|  | ||||
|     const user2Albums = await Promise.all([ | ||||
|     user2Albums = await Promise.all([ | ||||
|       api.albumApi.create(server, user2.accessToken, { | ||||
|         albumName: user2SharedUser, | ||||
|         sharedWithUserIds: [user1.userId], | ||||
|         assetIds: [user1Asset.id], | ||||
|       }), | ||||
|       api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), | ||||
|       api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), | ||||
| @@ -150,31 +158,30 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     // TODO: Add asset to album and test if it returns correctly. | ||||
|     it('should return the album collection filtered by assetId', async () => { | ||||
|       const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); | ||||
|       await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||
|         .get(`/album?assetId=${asset.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|       expect(status).toEqual(200); | ||||
|       expect(body).toHaveLength(0); | ||||
|       expect(body).toHaveLength(1); | ||||
|     }); | ||||
|  | ||||
|     // TODO: Add asset to album and test if it returns correctly. | ||||
|     it('should return the album collection filtered by assetId and ignores shared=true', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||
|         .get(`/album?shared=true&assetId=${user1Asset.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|       expect(status).toEqual(200); | ||||
|       expect(body).toHaveLength(0); | ||||
|       expect(body).toHaveLength(4); | ||||
|     }); | ||||
|  | ||||
|     // TODO: Add asset to album and test if it returns correctly. | ||||
|     it('should return the album collection filtered by assetId and ignores shared=false', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') | ||||
|         .get(`/album?shared=false&assetId=${user1Asset.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|       expect(status).toEqual(200); | ||||
|       expect(body).toHaveLength(0); | ||||
|       expect(body).toHaveLength(4); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @@ -205,6 +212,79 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /album/count', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get('/album/count'); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should return total count of albums the user has access to', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/album/count') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /album/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should return album info for own album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get(`/album/${user1Albums[0].id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual(user1Albums[0]); | ||||
|     }); | ||||
|  | ||||
|     it('should return album info for shared album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get(`/album/${user2Albums[0].id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual(user2Albums[0]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT /album/:id/assets', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should be able to add own asset to own album', async () => { | ||||
|       const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/album/${user1Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ ids: [asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); | ||||
|     }); | ||||
|  | ||||
|     it('should be able to add own asset to shared album', async () => { | ||||
|       const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/album/${user2Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ ids: [asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PATCH /album/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
| @@ -232,4 +312,107 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('DELETE /album/:id/assets', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .delete(`/album/${user1Albums[0].id}/assets`) | ||||
|         .send({ ids: [user1Asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should be able to remove own asset from own album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .delete(`/album/${user1Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ ids: [user1Asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); | ||||
|     }); | ||||
|  | ||||
|     it('should be able to remove own asset from shared album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .delete(`/album/${user2Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ ids: [user1Asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); | ||||
|     }); | ||||
|  | ||||
|     it('should not be able to remove foreign asset from own album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .delete(`/album/${user2Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user2.accessToken}`) | ||||
|         .send({ ids: [user1Asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); | ||||
|     }); | ||||
|  | ||||
|     it('should not be able to remove foreign asset from foreign album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .delete(`/album/${user1Albums[0].id}/assets`) | ||||
|         .set('Authorization', `Bearer ${user2.accessToken}`) | ||||
|         .send({ ids: [user1Asset.id] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT :id/users', () => { | ||||
|     let album: AlbumResponseDto; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' }); | ||||
|     }); | ||||
|  | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/album/${user1Albums[0].id}/users`) | ||||
|         .send({ sharedUserIds: [] }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should be able to add user to own album', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/album/${album.id}/users`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ sharedUserIds: [user2.userId] }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] })); | ||||
|     }); | ||||
|  | ||||
|     // it('should not be able to share album with owner', async () => { | ||||
|     //   const { status, body } = await request(server) | ||||
|     //     .put(`/album/${album.id}/users`) | ||||
|     //     .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|     //     .send({ sharedUserIds: [user2.userId] }); | ||||
|  | ||||
|     //   expect(status).toBe(400); | ||||
|     //   expect(body).toEqual(errorStub.badRequest); | ||||
|     // }); | ||||
|  | ||||
|     it('should not be able to add existing user to shared album', async () => { | ||||
|       await request(server) | ||||
|         .put(`/album/${album.id}/users`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ sharedUserIds: [user2.userId] }); | ||||
|  | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/album/${album.id}/users`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ sharedUserIds: [user2.userId] }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual({ ...errorStub.badRequest, message: 'User already added' }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain'; | ||||
| import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain'; | ||||
| import { AppModule, AssetController } from '@app/immich'; | ||||
| import { AssetEntity, AssetType } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub, uuidStub } from '@test/fixtures'; | ||||
| import { randomBytes } from 'crypto'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub, uuidStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| const user1Dto = { | ||||
|   email: 'user1@immich.app', | ||||
| @@ -22,8 +23,30 @@ const user2Dto = { | ||||
|   lastName: 'Test', | ||||
| }; | ||||
|  | ||||
| const makeUploadDto = (options?: { omit: string }): Record<string, any> => { | ||||
|   const dto: Record<string, any> = { | ||||
|     deviceAssetId: 'example-image', | ||||
|     deviceId: 'TEST', | ||||
|     fileCreatedAt: new Date().toISOString(), | ||||
|     fileModifiedAt: new Date().toISOString(), | ||||
|     isFavorite: 'testing', | ||||
|     duration: '0:00:00.000000', | ||||
|   }; | ||||
|  | ||||
|   const omit = options?.omit; | ||||
|   if (omit) { | ||||
|     delete dto[omit]; | ||||
|   } | ||||
|  | ||||
|   return dto; | ||||
| }; | ||||
|  | ||||
| let assetCount = 0; | ||||
| const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseDto): Promise<AssetEntity> => { | ||||
| const createAsset = ( | ||||
|   repository: IAssetRepository, | ||||
|   loginResponse: LoginResponseDto, | ||||
|   createdAt: Date, | ||||
| ): Promise<AssetEntity> => { | ||||
|   const id = assetCount++; | ||||
|   return repository.save({ | ||||
|     ownerId: loginResponse.userId, | ||||
| @@ -31,7 +54,7 @@ const createAsset = (repository: IAssetRepository, loginResponse: LoginResponseD | ||||
|     originalPath: `/tests/test_${id}`, | ||||
|     deviceAssetId: `test_${id}`, | ||||
|     deviceId: 'e2e-test', | ||||
|     fileCreatedAt: new Date(), | ||||
|     fileCreatedAt: createdAt, | ||||
|     fileModifiedAt: new Date(), | ||||
|     type: AssetType.IMAGE, | ||||
|     originalFileName: `test_${id}`, | ||||
| @@ -46,6 +69,8 @@ describe(`${AssetController.name} (e2e)`, () => { | ||||
|   let user2: LoginResponseDto; | ||||
|   let asset1: AssetEntity; | ||||
|   let asset2: AssetEntity; | ||||
|   let asset3: AssetEntity; | ||||
|   let asset4: AssetEntity; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||
| @@ -59,16 +84,18 @@ describe(`${AssetController.name} (e2e)`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     const admin = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     const admin = await api.authApi.adminLogin(server); | ||||
|  | ||||
|     await api.userApi.create(server, admin.accessToken, user1Dto); | ||||
|     user1 = await api.login(server, { email: user1Dto.email, password: user1Dto.password }); | ||||
|     asset1 = await createAsset(assetRepository, user1); | ||||
|     user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); | ||||
|     asset1 = await createAsset(assetRepository, user1, new Date('1970-01-01')); | ||||
|     asset2 = await createAsset(assetRepository, user1, new Date('1970-01-02')); | ||||
|     asset3 = await createAsset(assetRepository, user1, new Date('1970-02-01')); | ||||
|  | ||||
|     await api.userApi.create(server, admin.accessToken, user2Dto); | ||||
|     user2 = await api.login(server, { email: user2Dto.email, password: user2Dto.password }); | ||||
|     asset2 = await createAsset(assetRepository, user2); | ||||
|     user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }); | ||||
|     asset4 = await createAsset(assetRepository, user2, new Date('1970-01-01')); | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
| @@ -76,6 +103,83 @@ describe(`${AssetController.name} (e2e)`, () => { | ||||
|     await app.close(); | ||||
|   }); | ||||
|  | ||||
|   describe('POST /asset/upload', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .post(`/asset/upload`) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'TEST') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .field('isFavorite', false) | ||||
|         .field('duration', '0:00:00.000000') | ||||
|         .attach('assetData', randomBytes(32), 'example.jpg'); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     const invalid = [ | ||||
|       { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, | ||||
|       { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, | ||||
|       { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, | ||||
|       { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, | ||||
|       { should: 'require `isFavorite`', dto: { ...makeUploadDto({ omit: 'isFavorite' }) } }, | ||||
|       { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, | ||||
|       { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, | ||||
|       { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, | ||||
|       { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, | ||||
|     ]; | ||||
|  | ||||
|     for (const { should, dto } of invalid) { | ||||
|       it(`should ${should}`, async () => { | ||||
|         const { status, body } = await request(server) | ||||
|           .post('/asset/upload') | ||||
|           .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|           .attach('assetData', randomBytes(32), 'example.jpg') | ||||
|           .field(dto); | ||||
|         expect(status).toBe(400); | ||||
|         expect(body).toEqual(errorStub.badRequest); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     it('should upload a new asset', async () => { | ||||
|       const { body, status } = await request(server) | ||||
|         .post('/asset/upload') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'TEST') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .field('isFavorite', 'true') | ||||
|         .field('duration', '0:00:00.000000') | ||||
|         .attach('assetData', randomBytes(32), 'example.jpg'); | ||||
|       expect(status).toBe(201); | ||||
|       expect(body).toEqual({ id: expect.any(String), duplicate: false }); | ||||
|  | ||||
|       const asset = await api.assetApi.get(server, user1.accessToken, body.id); | ||||
|       expect(asset).toMatchObject({ id: body.id, isFavorite: true }); | ||||
|     }); | ||||
|  | ||||
|     it('should not upload the same asset twice', async () => { | ||||
|       const content = randomBytes(32); | ||||
|       await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); | ||||
|       const { body, status } = await request(server) | ||||
|         .post('/asset/upload') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'TEST') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .field('isFavorite', false) | ||||
|         .field('duration', '0:00:00.000000') | ||||
|         .attach('assetData', content, 'example.jpg'); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body.duplicate).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT /asset/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`); | ||||
| @@ -93,7 +197,7 @@ describe(`${AssetController.name} (e2e)`, () => { | ||||
|  | ||||
|     it('should require access', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/asset/${asset2.id}`) | ||||
|         .put(`/asset/${asset4.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorStub.noPermission); | ||||
| @@ -160,4 +264,198 @@ describe(`${AssetController.name} (e2e)`, () => { | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('POST /asset/download/info', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .post(`/asset/download/info`) | ||||
|         .send({ assetIds: [asset1.id] }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should download info', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .post('/asset/download/info') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ assetIds: [asset1.id] }); | ||||
|  | ||||
|       expect(status).toBe(201); | ||||
|       expect(body).toEqual(expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })] })); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('POST /asset/download/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).post(`/asset/download/${asset1.id}`); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should download file', async () => { | ||||
|       const asset = await api.assetApi.upload(server, user1.accessToken, 'example'); | ||||
|       const response = await request(server) | ||||
|         .post(`/asset/download/${asset.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|  | ||||
|       expect(response.status).toBe(200); | ||||
|       expect(response.headers['content-type']).toEqual('image/jpeg'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/statistics', () => { | ||||
|     beforeEach(async () => { | ||||
|       await api.assetApi.upload(server, user1.accessToken, 'favored_asset', { isFavorite: true }); | ||||
|       await api.assetApi.upload(server, user1.accessToken, 'archived_asset', { isArchived: true }); | ||||
|       await api.assetApi.upload(server, user1.accessToken, 'favored_archived_asset', { | ||||
|         isFavorite: true, | ||||
|         isArchived: true, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get('/album/statistics'); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should return stats of all assets', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ images: 6, videos: 0, total: 6 }); | ||||
|     }); | ||||
|  | ||||
|     it('should return stats of all favored assets', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ isFavorite: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ images: 2, videos: 0, total: 2 }); | ||||
|     }); | ||||
|  | ||||
|     it('should return stats of all archived assets', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ isArchived: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ images: 2, videos: 0, total: 2 }); | ||||
|     }); | ||||
|  | ||||
|     it('should return stats of all favored and archived assets', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ isFavorite: true, isArchived: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ images: 1, videos: 0, total: 1 }); | ||||
|     }); | ||||
|  | ||||
|     it('should return stats of all assets neither favored nor archived', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ isFavorite: false, isArchived: false }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ images: 3, videos: 0, total: 3 }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/time-buckets', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should get time buckets by month', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.MONTH }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() }, | ||||
|           { count: 2, timeBucket: asset1.fileCreatedAt.toISOString() }, | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should get time buckets by day', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.DAY }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { count: 1, timeBucket: asset1.fileCreatedAt.toISOString() }, | ||||
|           { count: 1, timeBucket: asset2.fileCreatedAt.toISOString() }, | ||||
|           { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() }, | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/time-bucket', () => { | ||||
|     let timeBucket: string; | ||||
|     beforeEach(async () => { | ||||
|       const { body, status } = await request(server) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.MONTH }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       timeBucket = body[1].timeBucket; | ||||
|     }); | ||||
|  | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/time-bucket') | ||||
|         .query({ size: TimeBucketSize.MONTH, timeBucket }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     // it('should fail if time bucket is invalid', async () => { | ||||
|     //   const { status, body } = await request(server) | ||||
|     //     .get('/asset/time-bucket') | ||||
|     //     .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|     //     .query({ size: TimeBucketSize.MONTH, timeBucket: 'foo' }); | ||||
|  | ||||
|     //   expect(status).toBe(400); | ||||
|     //   expect(body).toEqual(errorStub.badRequest); | ||||
|     // }); | ||||
|  | ||||
|     it('should return time bucket', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/asset/time-bucket') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.MONTH, timeBucket }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ id: asset1.id }), | ||||
|           expect.objectContaining({ id: asset2.id }), | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { AppModule, AuthController } from '@app/immich'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import request from 'supertest'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { | ||||
|   adminSignupStub, | ||||
|   changePasswordStub, | ||||
| @@ -11,8 +12,8 @@ import { | ||||
|   loginStub, | ||||
|   signupResponseStub, | ||||
|   uuidStub, | ||||
| } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
| } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
|  | ||||
| const firstName = 'Immich'; | ||||
| const lastName = 'Admin'; | ||||
| @@ -35,8 +36,8 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     const response = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     const response = await api.authApi.adminLogin(server); | ||||
|     accessToken = response.accessToken; | ||||
|   }); | ||||
|  | ||||
| @@ -67,7 +68,7 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|     } | ||||
|  | ||||
|     it(`should sign up the admin`, async () => { | ||||
|       await api.adminSignUp(server); | ||||
|       await api.authApi.adminSignUp(server); | ||||
|     }); | ||||
|  | ||||
|     it('should sign up the admin with a local domain', async () => { | ||||
| @@ -87,7 +88,7 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|     }); | ||||
|  | ||||
|     it('should not allow a second admin to sign up', async () => { | ||||
|       await api.adminSignUp(server); | ||||
|       await api.authApi.adminSignUp(server); | ||||
|  | ||||
|       const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); | ||||
|  | ||||
| @@ -152,7 +153,7 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('DELETE /auth/devices/:id', () => { | ||||
|   describe('DELETE /auth/devices', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).delete(`/auth/devices`); | ||||
|       expect(status).toBe(401); | ||||
| @@ -161,15 +162,15 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|  | ||||
|     it('should logout all devices (except the current one)', async () => { | ||||
|       for (let i = 0; i < 5; i++) { | ||||
|         await api.adminLogin(server); | ||||
|         await api.authApi.adminLogin(server); | ||||
|       } | ||||
|  | ||||
|       await expect(api.getAuthDevices(server, accessToken)).resolves.toHaveLength(6); | ||||
|       await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6); | ||||
|  | ||||
|       const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`); | ||||
|       expect(status).toBe(204); | ||||
|  | ||||
|       await api.validateToken(server, accessToken); | ||||
|       await api.authApi.validateToken(server, accessToken); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @@ -181,7 +182,7 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|     }); | ||||
|  | ||||
|     it('should logout a device', async () => { | ||||
|       const [device] = await api.getAuthDevices(server, accessToken); | ||||
|       const [device] = await api.authApi.getAuthDevices(server, accessToken); | ||||
|       const { status } = await request(server) | ||||
|         .delete(`/auth/devices/${device.id}`) | ||||
|         .set('Authorization', `Bearer ${accessToken}`); | ||||
| @@ -244,7 +245,7 @@ describe(`${AuthController.name} (e2e)`, () => { | ||||
|         .set('Authorization', `Bearer ${accessToken}`); | ||||
|       expect(status).toBe(200); | ||||
|  | ||||
|       await api.login(server, { email: 'admin@immich.app', password: 'Password1234' }); | ||||
|       await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import { AppModule, OAuthController } from '@app/immich'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| describe(`${OAuthController.name} (e2e)`, () => { | ||||
|   let app: INestApplication; | ||||
| @@ -20,7 +21,7 @@ describe(`${OAuthController.name} (e2e)`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
|   | ||||
							
								
								
									
										146
									
								
								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 { PersonEntity } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub, uuidStub } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub, uuidStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| describe(`${PersonController.name}`, () => { | ||||
|   let app: INestApplication; | ||||
|   let server: any; | ||||
|   let loginResponse: LoginResponseDto; | ||||
|   let accessToken: string; | ||||
|   let personRepository: IPersonRepository; | ||||
|   let faceRepository: IFaceRepository; | ||||
|   let visiblePerson: PersonEntity; | ||||
|   let hiddenPerson: PersonEntity; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     const moduleFixture: TestingModule = await Test.createTestingModule({ | ||||
| @@ -19,13 +25,31 @@ describe(`${PersonController.name}`, () => { | ||||
|  | ||||
|     app = await moduleFixture.createNestApplication().init(); | ||||
|     server = app.getHttpServer(); | ||||
|     personRepository = app.get<IPersonRepository>(IPersonRepository); | ||||
|     faceRepository = app.get<IFaceRepository>(IFaceRepository); | ||||
|   }); | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     loginResponse = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     loginResponse = await api.authApi.adminLogin(server); | ||||
|     accessToken = loginResponse.accessToken; | ||||
|  | ||||
|     const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset'); | ||||
|     visiblePerson = await personRepository.create({ | ||||
|       ownerId: loginResponse.userId, | ||||
|       name: 'visible_person', | ||||
|       thumbnailPath: '/thumbnail/face_asset', | ||||
|     }); | ||||
|     await faceRepository.create({ assetId: faceAsset.id, personId: visiblePerson.id }); | ||||
|  | ||||
|     hiddenPerson = await personRepository.create({ | ||||
|       ownerId: loginResponse.userId, | ||||
|       name: 'hidden_person', | ||||
|       isHidden: true, | ||||
|       thumbnailPath: '/thumbnail/face_asset', | ||||
|     }); | ||||
|     await faceRepository.create({ assetId: faceAsset.id, personId: hiddenPerson.id }); | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
| @@ -33,6 +57,72 @@ describe(`${PersonController.name}`, () => { | ||||
|     await app.close(); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /person', () => { | ||||
|     beforeEach(async () => {}); | ||||
|  | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get('/person'); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should return all people (including hidden)', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/person') | ||||
|         .set('Authorization', `Bearer ${accessToken}`) | ||||
|         .query({ withHidden: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         total: 2, | ||||
|         visible: 1, | ||||
|         people: [ | ||||
|           expect.objectContaining({ name: 'visible_person' }), | ||||
|           expect.objectContaining({ name: 'hidden_person' }), | ||||
|         ], | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should return only visible people', async () => { | ||||
|       const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         total: 1, | ||||
|         visible: 1, | ||||
|         people: [expect.objectContaining({ name: 'visible_person' })], | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /person/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should throw error if person with id does not exist', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get(`/person/${uuidStub.notFound}`) | ||||
|         .set('Authorization', `Bearer ${accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorStub.badRequest); | ||||
|     }); | ||||
|  | ||||
|     it('should return person information', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .get(`/person/${visiblePerson.id}`) | ||||
|         .set('Authorization', `Bearer ${accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id })); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT /person/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`); | ||||
| @@ -42,10 +132,8 @@ describe(`${PersonController.name}`, () => { | ||||
|  | ||||
|     for (const key of ['name', 'featureFaceAssetId', 'isHidden']) { | ||||
|       it(`should not allow null ${key}`, async () => { | ||||
|         const personRepository = app.get<IPersonRepository>(IPersonRepository); | ||||
|         const person = await personRepository.create({ ownerId: loginResponse.userId }); | ||||
|         const { status, body } = await request(server) | ||||
|           .put(`/person/${person.id}`) | ||||
|           .put(`/person/${visiblePerson.id}`) | ||||
|           .set('Authorization', `Bearer ${accessToken}`) | ||||
|           .send({ [key]: null }); | ||||
|         expect(status).toBe(400); | ||||
| @@ -65,10 +153,8 @@ describe(`${PersonController.name}`, () => { | ||||
|     }); | ||||
|  | ||||
|     it('should update a date of birth', async () => { | ||||
|       const personRepository = app.get<IPersonRepository>(IPersonRepository); | ||||
|       const person = await personRepository.create({ ownerId: loginResponse.userId }); | ||||
|       const { status, body } = await request(server) | ||||
|         .put(`/person/${person.id}`) | ||||
|         .put(`/person/${visiblePerson.id}`) | ||||
|         .set('Authorization', `Bearer ${accessToken}`) | ||||
|         .send({ birthDate: '1990-01-01T05:00:00.000Z' }); | ||||
|       expect(status).toBe(200); | ||||
| @@ -76,7 +162,6 @@ describe(`${PersonController.name}`, () => { | ||||
|     }); | ||||
|  | ||||
|     it('should clear a date of birth', async () => { | ||||
|       const personRepository = app.get<IPersonRepository>(IPersonRepository); | ||||
|       const person = await personRepository.create({ | ||||
|         birthDate: new Date('1990-01-01'), | ||||
|         ownerId: loginResponse.userId, | ||||
|   | ||||
| @@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain'; | ||||
| import { AppModule, ServerInfoController } from '@app/immich'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| describe(`${ServerInfoController.name} (e2e)`, () => { | ||||
|   let app: INestApplication; | ||||
| @@ -23,8 +24,8 @@ describe(`${ServerInfoController.name} (e2e)`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     loginResponse = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     loginResponse = await api.authApi.adminLogin(server); | ||||
|     accessToken = loginResponse.accessToken; | ||||
|   }); | ||||
|  | ||||
| @@ -116,7 +117,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { | ||||
|     it('should only work for admins', async () => { | ||||
|       const loginDto = { email: 'test@immich.app', password: 'Immich123' }; | ||||
|       await api.userApi.create(server, accessToken, { ...loginDto, firstName: 'test', lastName: 'test' }); | ||||
|       const { accessToken: userAccessToken } = await api.login(server, loginDto); | ||||
|       const { accessToken: userAccessToken } = await api.authApi.login(server, loginDto); | ||||
|       const { status, body } = await request(server) | ||||
|         .get('/server-info/stats') | ||||
|         .set('Authorization', `Bearer ${userAccessToken}`); | ||||
|   | ||||
							
								
								
									
										241
									
								
								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 { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import { api } from '@test/api'; | ||||
| import { db } from '@test/db'; | ||||
| import { errorStub, userSignupStub, userStub } from '@test/fixtures'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub, userSignupStub, userStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| describe(`${UserController.name}`, () => { | ||||
|   let app: INestApplication; | ||||
| @@ -23,8 +24,8 @@ describe(`${UserController.name}`, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await db.reset(); | ||||
|     await api.adminSignUp(server); | ||||
|     loginResponse = await api.adminLogin(server); | ||||
|     await api.authApi.adminSignUp(server); | ||||
|     loginResponse = await api.authApi.adminLogin(server); | ||||
|     accessToken = loginResponse.accessToken; | ||||
|   }); | ||||
|  | ||||
|   | ||||
							
								
								
									
										11
									
								
								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 { AssetEntity } from '@app/infra/entities'; | ||||
| import { assetStub } from '.'; | ||||
|  | ||||
| export const searchStub = { | ||||
|   emptyResults: Object.freeze<SearchResult<any>>({ | ||||
| @@ -9,4 +11,13 @@ export const searchStub = { | ||||
|     facets: [], | ||||
|     distances: [], | ||||
|   }), | ||||
|  | ||||
|   withImage: Object.freeze<SearchResult<AssetEntity>>({ | ||||
|     total: 1, | ||||
|     count: 1, | ||||
|     page: 1, | ||||
|     items: [assetStub.image], | ||||
|     facets: [], | ||||
|     distances: [], | ||||
|   }), | ||||
| }; | ||||
|   | ||||
| @@ -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