diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index f9ab15374415b..f1f5c2aee94be 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -153,44 +153,30 @@ describe(AlbumService.name, () => { describe('create', () => { it('creates album', async () => { albumMock.create.mockResolvedValue(albumStub.empty); + userMock.get.mockResolvedValue(userStub.user1); - await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ + await sut.create(authStub.admin, { albumName: 'Empty album', + sharedWithUserIds: ['user-id'], description: '', - albumThumbnailAssetId: null, - assetCount: 0, - assets: [], - createdAt: expect.anything(), - id: 'album-1', - owner: { - email: 'admin@test.com', - firstName: 'admin_first_name', - id: 'admin_id', - isAdmin: true, - lastName: 'admin_last_name', - oauthId: '', - profileImagePath: '', - shouldChangePassword: false, - storageLabel: 'admin', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - externalPath: null, - memoriesEnabled: true, - }, - ownerId: 'admin_id', - shared: false, - sharedUsers: [], - startDate: undefined, - endDate: undefined, - hasSharedLink: false, - updatedAt: expect.anything(), + assetIds: ['123'], }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [albumStub.empty.id] }, }); + + expect(albumMock.create).toHaveBeenCalledWith({ + ownerId: authStub.admin.id, + albumName: albumStub.empty.albumName, + description: albumStub.empty.description, + sharedUsers: [{ id: 'user-id' }], + assets: [{ id: '123' }], + albumThumbnailAssetId: '123', + }); + + expect(userMock.get).toHaveBeenCalledWith('user-id'); }); it('should require valid userIds', async () => { diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 3569297d0a2fc..53bd0033c5716 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -136,9 +136,6 @@ export class AlbumService { await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); const album = await this.findOrFail(id, { withAssets: false }); - if (!album) { - throw new BadRequestException('Album not found'); - } await this.albumRepository.delete(album); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); @@ -228,6 +225,10 @@ export class AlbumService { const album = await this.findOrFail(id, { withAssets: false }); for (const userId of dto.sharedUserIds) { + if (album.ownerId === userId) { + throw new BadRequestException('Cannot be shared with owner'); + } + const exists = album.sharedUsers.find((user) => user.id === userId); if (exists) { throw new BadRequestException('User already added'); diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 457690014149c..a52fb822346b6 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -15,7 +15,7 @@ import { Readable } from 'stream'; import { ICryptoRepository } from '../crypto'; import { IJobRepository, JobName } from '../job'; import { IStorageRepository } from '../storage'; -import { AssetStats, IAssetRepository } from './asset.repository'; +import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository'; import { AssetService, UploadFieldName } from './asset.service'; import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { mapAsset } from './response-dto'; @@ -330,6 +330,73 @@ describe(AssetService.name, () => { }); }); + describe('getTimeBuckets', () => { + it("should return buckets if userId and albumId aren't set", async () => { + assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await expect( + sut.getTimeBuckets(authStub.admin, { + size: TimeBucketSize.DAY, + }), + ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userId: authStub.admin.id }); + }); + }); + + describe('getByTimeBucket', () => { + it('should return the assets for a album time bucket if user has album.read', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getByTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-id'); + expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + albumId: 'album-id', + }); + }); + + it('should return the assets for a archive time bucket if user has archive.read', async () => { + assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getByTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userId: authStub.admin.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userId: authStub.admin.id, + }); + }); + + it('should return the assets for a library time bucket if user has library.read', async () => { + assetMock.getByTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getByTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getByTimeBucket).toBeCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.id, + }); + }); + }); + describe('downloadFile', () => { it('should require the asset.download permission', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index e0fefabd2824d..ab8e59405caa5 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/server/src/domain/auth/auth.service.spec.ts @@ -214,6 +214,15 @@ describe('AuthService', () => { expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123'); }); + + it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { + const authUser = { id: '123' } as AuthUserDto; + + await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({ + successful: true, + redirectUri: '/auth/login?autoLaunch=0', + }); + }); }); describe('adminSignUp', () => { diff --git a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts index 9621f9e86fb25..00319e714cece 100644 --- a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts +++ b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts @@ -1,4 +1,4 @@ -import { Colorspace } from '@app/infra/entities'; +import { Colorspace, SystemConfigKey } from '@app/infra/entities'; import { assetStub, faceStub, @@ -137,6 +137,14 @@ describe(FacialRecognitionService.name, () => { }); describe('handleQueueRecognizeFaces', () => { + it('should return if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(configMock.load).toHaveBeenCalled(); + }); + it('should queue missing assets', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], @@ -170,6 +178,14 @@ describe(FacialRecognitionService.name, () => { }); describe('handleRecognizeFaces', () => { + it('should return if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await expect(sut.handleRecognizeFaces({ id: 'foo' })).resolves.toBe(true); + expect(assetMock.getByIds).not.toHaveBeenCalled(); + expect(configMock.load).toHaveBeenCalled(); + }); + it('should skip when no resize path', async () => { assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id }); @@ -260,6 +276,14 @@ describe(FacialRecognitionService.name, () => { }); describe('handleGenerateFaceThumbnail', () => { + it('should return if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await expect(sut.handleGenerateFaceThumbnail(face.middle)).resolves.toBe(true); + expect(assetMock.getByIds).not.toHaveBeenCalled(); + expect(configMock.load).toHaveBeenCalled(); + }); + it('should skip an asset not found', async () => { assetMock.getByIds.mockResolvedValue([]); diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index f8a323bbba7cd..2c144baa95d78 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -288,6 +288,17 @@ describe(JobService.name, () => { JobName.VIDEO_CONVERSION, ], }, + { + item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [ + JobName.CLASSIFY_IMAGE, + JobName.GENERATE_WEBP_THUMBNAIL, + JobName.RECOGNIZE_FACES, + JobName.GENERATE_THUMBHASH_THUMBNAIL, + JobName.ENCODE_CLIP, + JobName.VIDEO_CONVERSION, + ], + }, { item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } }, jobs: [JobName.SEARCH_INDEX_ASSET], @@ -305,7 +316,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { if (item.name === JobName.GENERATE_JPEG_THUMBNAIL && item.data.source === 'upload') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + if (item.data.id === 'asset-live-image') { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + } else { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + } } else { assetMock.getByIds.mockResolvedValue([]); } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 03ba41a9b72cd..c37abdd6d0491 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -215,6 +215,15 @@ describe(PersonService.name, () => { }, }); }); + + it('should throw an error when the face feature assetId is invalid', async () => { + personMock.getById.mockResolvedValue(personStub.withName); + + await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( + BadRequestException, + ); + expect(personMock.update).not.toHaveBeenCalled(); + }); }); describe('updateAll', () => { diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index d73c269ca49c6..148f49f61e34d 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { albumStub, assetStub, @@ -15,12 +16,13 @@ import { } from '@test'; import { plainToInstance } from 'class-transformer'; import { IAlbumRepository } from '../album/album.repository'; +import { mapAsset } from '../asset'; import { IAssetRepository } from '../asset/asset.repository'; import { IFaceRepository } from '../facial-recognition'; -import { ISystemConfigRepository } from '../index'; import { JobName } from '../job'; import { IJobRepository } from '../job/job.repository'; import { IMachineLearningRepository } from '../smart-info'; +import { ISystemConfigRepository } from '../system-config'; import { SearchDto } from './dto'; import { ISearchRepository } from './search.repository'; import { SearchService } from './search.service'; @@ -50,9 +52,17 @@ describe(SearchService.name, () => { searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); + delete process.env.TYPESENSE_ENABLED; await sut.init(); }); + const disableSearch = () => { + searchMock.setup.mockClear(); + searchMock.checkMigrationStatus.mockClear(); + jobMock.queue.mockClear(); + process.env.TYPESENSE_ENABLED = 'false'; + }; + afterEach(() => { sut.teardown(); }); @@ -84,15 +94,14 @@ describe(SearchService.name, () => { }); describe(`init`, () => { - // it('should skip when search is disabled', async () => { - // await sut.init(); + 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', + }); }); }); diff --git a/server/src/domain/smart-info/smart-info.service.spec.ts b/server/src/domain/smart-info/smart-info.service.spec.ts index 8ff1437a0c2a1..f6e20aa5f8232 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, SystemConfigKey } from '@app/infra/entities'; import { assetStub, newAssetRepositoryMock, @@ -43,6 +43,15 @@ describe(SmartInfoService.name, () => { }); describe('handleQueueObjectTagging', () => { + it('should do nothing if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await sut.handleQueueObjectTagging({}); + + expect(assetMock.getAll).not.toHaveBeenCalled(); + expect(assetMock.getWithout).not.toHaveBeenCalled(); + }); + it('should queue the assets without tags', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], @@ -68,7 +77,16 @@ describe(SmartInfoService.name, () => { }); }); - describe('handleTagImage', () => { + describe('handleClassifyImage', () => { + it('should do nothing if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await sut.handleClassifyImage({ id: '123' }); + + expect(machineMock.classifyImage).not.toHaveBeenCalled(); + expect(assetMock.getByIds).not.toHaveBeenCalled(); + }); + it('should skip assets without a resize path', async () => { const asset = { resizePath: '' } as AssetEntity; assetMock.getByIds.mockResolvedValue([asset]); @@ -108,6 +126,15 @@ describe(SmartInfoService.name, () => { }); describe('handleQueueEncodeClip', () => { + it('should do nothing if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await sut.handleQueueEncodeClip({}); + + expect(assetMock.getAll).not.toHaveBeenCalled(); + expect(assetMock.getWithout).not.toHaveBeenCalled(); + }); + it('should queue the assets without clip embeddings', async () => { assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], @@ -134,6 +161,15 @@ describe(SmartInfoService.name, () => { }); describe('handleEncodeClip', () => { + it('should do nothing if machine learning is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + + await sut.handleEncodeClip({ id: '123' }); + + expect(assetMock.getByIds).not.toHaveBeenCalled(); + expect(machineMock.encodeImage).not.toHaveBeenCalled(); + }); + it('should skip assets without a resize path', async () => { const asset = { resizePath: '' } as AssetEntity; assetMock.getByIds.mockResolvedValue([asset]); diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index a7110dc8e7c1d..f31edaf5df5d4 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -34,6 +34,41 @@ describe(StorageTemplateService.name, () => { sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock); }); + describe('handleMigrationSingle', () => { + it('should migrate single moving picture', async () => { + userMock.get.mockResolvedValue(userStub.user1); + const path = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}.jpg`; + const newPath = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}+1.jpg`; + + when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoStillAsset.id)).mockResolvedValue(true); + when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoStillAsset.id)).mockResolvedValue(false); + + when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(true); + when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(false); + + when(assetMock.save) + .calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newPath(assetStub.livePhotoStillAsset.id) }) + .mockResolvedValue(assetStub.livePhotoStillAsset); + + when(assetMock.save) + .calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newPath(assetStub.livePhotoMotionAsset.id) }) + .mockResolvedValue(assetStub.livePhotoMotionAsset); + + when(assetMock.getByIds) + .calledWith([assetStub.livePhotoStillAsset.id]) + .mockResolvedValue([assetStub.livePhotoStillAsset]); + + when(assetMock.getByIds) + .calledWith([assetStub.livePhotoMotionAsset.id]) + .mockResolvedValue([assetStub.livePhotoMotionAsset]); + + await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); + + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + }); + }); + describe('handle template migration', () => { it('should handle no assets', async () => { assetMock.getAll.mockResolvedValue({ diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index ce49faffc787a..8941ef823c06f 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -89,7 +89,6 @@ export class StorageTemplateService { return true; } - // TODO: use asset core (once in domain) async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { if (asset.isReadOnly) { this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); @@ -121,7 +120,7 @@ export class StorageTemplateService { error?.stack, ); - // Either sidecar move failed or the save failed. Eithr way, move media back + // Either sidecar move failed or the save failed. Either way, move media back await this.storageRepository.moveFile(destination, source); if (asset.sidecarPath && sidecarDestination && sidecarMoved) { diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index ef1a07d1770a9..32ffe7575833b 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -1,5 +1,10 @@ import { UserEntity } from '@app/infra/entities'; -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { newAlbumRepositoryMock, newAssetRepositoryMock, @@ -7,6 +12,7 @@ import { newJobRepositoryMock, newStorageRepositoryMock, newUserRepositoryMock, + userStub, } from '@test'; import { when } from 'jest-when'; import { IAlbumRepository } from '../album'; @@ -16,7 +22,7 @@ import { ICryptoRepository } from '../crypto'; import { IJobRepository, JobName } from '../job'; import { IStorageRepository } from '../storage'; import { UpdateUserDto } from './dto/update-user.dto'; -import { UserResponseDto } from './response-dto'; +import { UserResponseDto, mapUser } from './response-dto'; import { IUserRepository } from './user.repository'; import { UserService } from './user.service'; @@ -216,6 +222,13 @@ describe(UserService.name, () => { expect(userMock.getList).toHaveBeenCalled(); expect(response).toEqual({ userCount: 1 }); }); + + it('should get the user count of all admin users', async () => { + userMock.getList.mockResolvedValue([adminUser, immichUser]); + + await expect(sut.getCount({ admin: true })).resolves.toEqual({ userCount: 1 }); + expect(userMock.getList).toHaveBeenCalled(); + }); }); describe('update', () => { @@ -223,12 +236,17 @@ describe(UserService.name, () => { const update: UpdateUserDto = { id: immichUser.id, shouldChangePassword: true, + email: 'immich@test.com', + storageLabel: 'storage_label', }; + userMock.getByEmail.mockResolvedValue(null); + userMock.getByStorageLabel.mockResolvedValue(null); + userMock.update.mockResolvedValue({ ...updatedImmichUser, isAdmin: true, storageLabel: 'storage_label' }); - when(userMock.update).calledWith(update.id, update).mockResolvedValueOnce(updatedImmichUser); - - const updatedUser = await sut.update(immichUserAuth, update); + const updatedUser = await sut.update({ ...immichUserAuth, isAdmin: true }, update); expect(updatedUser.shouldChangePassword).toEqual(true); + expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); + expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); }); it('should not set an empty string for storage label', async () => { @@ -345,20 +363,37 @@ describe(UserService.name, () => { }); describe('restore', () => { + it('should throw error if user could not be found', async () => { + userMock.get.mockResolvedValue(null); + + await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); + expect(userMock.restore).not.toHaveBeenCalled(); + }); + it('should require an admin', async () => { when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser); await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException); expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true); }); - it('should require the auth user be an admin', async () => { - await expect(sut.delete(immichUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); + it('should restore an user', async () => { + userMock.get.mockResolvedValue(immichUser); + userMock.restore.mockResolvedValue(immichUser); - expect(userMock.delete).not.toHaveBeenCalled(); + await expect(sut.restore(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); + expect(userMock.get).toHaveBeenCalledWith(immichUser.id, true); + expect(userMock.restore).toHaveBeenCalledWith(immichUser); }); }); describe('delete', () => { + it('should throw error if user could not be found', async () => { + userMock.get.mockResolvedValue(null); + + await expect(sut.delete(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); + expect(userMock.delete).not.toHaveBeenCalled(); + }); + it('cannot delete admin user', async () => { await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); }); @@ -368,9 +403,18 @@ describe(UserService.name, () => { expect(userMock.delete).not.toHaveBeenCalled(); }); + + it('should delete user', async () => { + userMock.get.mockResolvedValue(immichUser); + userMock.delete.mockResolvedValue(immichUser); + + await expect(sut.delete(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); + expect(userMock.get).toHaveBeenCalledWith(immichUser.id, undefined); + expect(userMock.delete).toHaveBeenCalledWith(immichUser); + }); }); - describe('update', () => { + describe('create', () => { it('should not create a user if there is no local admin account', async () => { when(userMock.getAdmin).calledWith().mockResolvedValueOnce(null); @@ -383,6 +427,30 @@ describe(UserService.name, () => { }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('should create user', async () => { + userMock.getAdmin.mockResolvedValue(userStub.admin); + userMock.create.mockResolvedValue(userStub.user1); + + await expect( + sut.create({ + email: userStub.user1.email, + firstName: userStub.user1.firstName, + lastName: userStub.user1.lastName, + password: 'password', + storageLabel: 'label', + }), + ).resolves.toEqual(mapUser(userStub.user1)); + + expect(userMock.getAdmin).toBeCalled(); + expect(userMock.create).toBeCalledWith({ + email: userStub.user1.email, + firstName: userStub.user1.firstName, + lastName: userStub.user1.lastName, + storageLabel: 'label', + password: expect.anything(), + }); + }); }); describe('createProfileImage', () => { @@ -394,6 +462,13 @@ describe(UserService.name, () => { expect(userMock.update).toHaveBeenCalledWith(adminUserAuth.id, { profileImagePath: file.path }); }); + + it('should throw an error if the user profile could not be updated with the new image', async () => { + const file = { path: '/profile/path' } as Express.Multer.File; + userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); + + await expect(sut.createProfileImage(adminUserAuth, file)).rejects.toThrowError(InternalServerErrorException); + }); }); describe('getUserProfileImage', () => { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 52d9f40c5ef4b..8c7c17e1c189a 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -63,7 +63,7 @@ export class AssetController { async uploadFile( @AuthUser() authUser: AuthUserDto, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, - @Body(new ValidationPipe()) dto: CreateAssetDto, + @Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto, @Response({ passthrough: true }) res: Res, ): Promise { 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 { const responseDto = await this.assetService.importFile(authUser, dto); diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 6f0601672bcf2..cf80a342456de 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,33 +1,43 @@ import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator'; export class CreateAssetBase { @IsNotEmpty() + @IsString() deviceAssetId!: string; @IsNotEmpty() + @IsString() deviceId!: string; @IsNotEmpty() + @IsDate() + @Type(() => Date) fileCreatedAt!: Date; @IsNotEmpty() + @IsDate() + @Type(() => Date) fileModifiedAt!: Date; - @IsNotEmpty() + @IsBoolean() + @Transform(toBoolean) isFavorite!: boolean; @Optional() @IsBoolean() + @Transform(toBoolean) isArchived?: boolean; @Optional() @IsBoolean() + @Transform(toBoolean) isVisible?: boolean; @Optional() + @IsString() duration?: string; } @@ -51,6 +61,7 @@ export class CreateAssetDto extends CreateAssetBase { export class ImportAssetDto extends CreateAssetBase { @Optional() + @IsBoolean() @Transform(toBoolean) isReadOnly?: boolean = true; diff --git a/server/test/api/album-api.ts b/server/test/api/album-api.ts new file mode 100644 index 0000000000000..3364c3452795a --- /dev/null +++ b/server/test/api/album-api.ts @@ -0,0 +1,18 @@ +import { AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; +import request from 'supertest'; + +export const albumApi = { + create: async (server: any, accessToken: string, dto: CreateAlbumDto) => { + const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status).toEqual(201); + return res.body as AlbumResponseDto; + }, + addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => { + const res = await request(server) + .put(`/album/${id}/assets`) + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + expect(res.status).toEqual(200); + return res.body as BulkIdResponseDto[]; + }, +}; diff --git a/server/test/api/asset-api.ts b/server/test/api/asset-api.ts new file mode 100644 index 0000000000000..e433f6dc593b0 --- /dev/null +++ b/server/test/api/asset-api.ts @@ -0,0 +1,34 @@ +import { AssetResponseDto } from '@app/domain'; +import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto'; +import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; +import { randomBytes } from 'crypto'; +import request from 'supertest'; + +type UploadDto = Partial & { 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; + }, +}; diff --git a/server/test/api/auth-api.ts b/server/test/api/auth-api.ts new file mode 100644 index 0000000000000..5f1750963210c --- /dev/null +++ b/server/test/api/auth-api.ts @@ -0,0 +1,46 @@ +import { AdminSignupResponseDto, AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto } from '@app/domain'; +import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from '@test'; +import request from 'supertest'; + +export const authApi = { + adminSignUp: async (server: any) => { + const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); + + expect(status).toBe(201); + expect(body).toEqual(signupResponseStub); + + return body as AdminSignupResponseDto; + }, + adminLogin: async (server: any) => { + const { status, body } = await request(server).post('/auth/login').send(loginStub.admin); + + expect(body).toEqual(loginResponseStub.admin.response); + expect(body).toMatchObject({ accessToken: expect.any(String) }); + expect(status).toBe(201); + + return body as LoginResponseDto; + }, + login: async (server: any, dto: LoginCredentialDto) => { + const { status, body } = await request(server).post('/auth/login').send(dto); + + expect(status).toEqual(201); + expect(body).toMatchObject({ accessToken: expect.any(String) }); + + return body as LoginResponseDto; + }, + getAuthDevices: async (server: any, accessToken: string) => { + const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`); + + expect(body).toEqual(expect.any(Array)); + expect(status).toBe(200); + + return body as AuthDeviceResponseDto[]; + }, + validateToken: async (server: any, accessToken: string) => { + const { status, body } = await request(server) + .post('/auth/validateToken') + .set('Authorization', `Bearer ${accessToken}`); + expect(body).toEqual({ authStatus: true }); + expect(status).toBe(200); + }, +}; diff --git a/server/test/api/index.ts b/server/test/api/index.ts new file mode 100644 index 0000000000000..38881a0113859 --- /dev/null +++ b/server/test/api/index.ts @@ -0,0 +1,13 @@ +import { albumApi } from './album-api'; +import { assetApi } from './asset-api'; +import { authApi } from './auth-api'; +import { sharedLinkApi } from './shared-link-api'; +import { userApi } from './user-api'; + +export const api = { + authApi, + assetApi, + sharedLinkApi, + albumApi, + userApi, +}; diff --git a/server/test/api/shared-link-api.ts b/server/test/api/shared-link-api.ts new file mode 100644 index 0000000000000..c34093b0ac769 --- /dev/null +++ b/server/test/api/shared-link-api.ts @@ -0,0 +1,13 @@ +import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain'; +import request from 'supertest'; + +export const sharedLinkApi = { + create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + expect(status).toBe(201); + return body as SharedLinkResponseDto; + }, +}; diff --git a/server/test/api/user-api.ts b/server/test/api/user-api.ts new file mode 100644 index 0000000000000..20acf50c3801e --- /dev/null +++ b/server/test/api/user-api.ts @@ -0,0 +1,47 @@ +import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain'; +import request from 'supertest'; + +export const userApi = { + create: async (server: any, accessToken: string, dto: CreateUserDto) => { + const { status, body } = await request(server) + .post('/user') + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + email: dto.email, + }); + + return body as UserResponseDto; + }, + get: async (server: any, accessToken: string, id: string) => { + const { status, body } = await request(server) + .get(`/user/info/${id}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id }); + + return body as UserResponseDto; + }, + update: async (server: any, accessToken: string, dto: UpdateUserDto) => { + const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto); + + expect(status).toBe(200); + expect(body).toMatchObject({ id: dto.id }); + + return body as UserResponseDto; + }, + delete: async (server: any, accessToken: string, id: string) => { + const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); + + return body as UserResponseDto; + }, +}; diff --git a/server/test/db/index.ts b/server/test/db/index.ts new file mode 100644 index 0000000000000..dd92f0e276365 --- /dev/null +++ b/server/test/db/index.ts @@ -0,0 +1,24 @@ +import { dataSource } from '@app/infra'; + +export const db = { + reset: async () => { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + + await dataSource.transaction(async (em) => { + for (const entity of dataSource.entityMetadatas) { + if (entity.tableName === 'users') { + continue; + } + await em.query(`DELETE FROM ${entity.tableName} CASCADE;`); + } + await em.query(`DELETE FROM "users" CASCADE;`); + }); + }, + disconnect: async () => { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + }, +}; diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index f50c850ca8016..78a20f8a25340 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -1,11 +1,13 @@ -import { LoginResponseDto } from '@app/domain'; +import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; import { AlbumController, AppModule } from '@app/immich'; +import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; import { SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { errorStub, uuidStub } from '../fixtures'; -import { api, db } from '../test-utils'; const user1SharedUser = 'user1SharedUser'; const user1SharedLink = 'user1SharedLink'; @@ -18,7 +20,10 @@ describe(`${AlbumController.name} (e2e)`, () => { let app: INestApplication; let server: any; let user1: LoginResponseDto; + let user1Asset: AssetFileUploadResponseDto; + let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; + let user2Albums: AlbumResponseDto[]; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -31,8 +36,8 @@ describe(`${AlbumController.name} (e2e)`, () => { beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); - const admin = await api.adminLogin(server); + await api.authApi.adminSignUp(server); + const admin = await api.authApi.adminLogin(server); await api.userApi.create(server, admin.accessToken, { email: 'user1@immich.app', @@ -40,7 +45,7 @@ describe(`${AlbumController.name} (e2e)`, () => { firstName: 'User 1', lastName: 'Test', }); - user1 = await api.login(server, { email: 'user1@immich.app', password: 'Password123' }); + user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); await api.userApi.create(server, admin.accessToken, { email: 'user2@immich.app', @@ -48,15 +53,17 @@ describe(`${AlbumController.name} (e2e)`, () => { firstName: 'User 2', lastName: 'Test', }); - user2 = await api.login(server, { email: 'user2@immich.app', password: 'Password123' }); + user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }); - const user1Albums = await Promise.all([ + user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example'); + user1Albums = await Promise.all([ api.albumApi.create(server, user1.accessToken, { albumName: user1SharedUser, sharedWithUserIds: [user2.userId], + assetIds: [user1Asset.id], }), - api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink }), - api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared }), + api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), + api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), ]); // add shared link to user1SharedLink album @@ -65,10 +72,11 @@ describe(`${AlbumController.name} (e2e)`, () => { albumId: user1Albums[1].id, }); - const user2Albums = await Promise.all([ + user2Albums = await Promise.all([ api.albumApi.create(server, user2.accessToken, { albumName: user2SharedUser, sharedWithUserIds: [user1.userId], + assetIds: [user1Asset.id], }), api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), @@ -150,31 +158,30 @@ describe(`${AlbumController.name} (e2e)`, () => { ); }); - // TODO: Add asset to album and test if it returns correctly. it('should return the album collection filtered by assetId', async () => { + const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); + await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); const { status, body } = await request(server) - .get('/album?assetId=ecb120db-45a2-4a65-9293-51476f0d8790') + .get(`/album?assetId=${asset.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(0); + expect(body).toHaveLength(1); }); - // TODO: Add asset to album and test if it returns correctly. it('should return the album collection filtered by assetId and ignores shared=true', async () => { const { status, body } = await request(server) - .get('/album?shared=true&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') + .get(`/album?shared=true&assetId=${user1Asset.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(0); + expect(body).toHaveLength(4); }); - // TODO: Add asset to album and test if it returns correctly. it('should return the album collection filtered by assetId and ignores shared=false', async () => { const { status, body } = await request(server) - .get('/album?shared=false&assetId=ecb120db-45a2-4a65-9293-51476f0d8790') + .get(`/album?shared=false&assetId=${user1Asset.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(0); + expect(body).toHaveLength(4); }); }); @@ -205,6 +212,79 @@ describe(`${AlbumController.name} (e2e)`, () => { }); }); + describe('GET /album/count', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/album/count'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should return total count of albums the user has access to', async () => { + const { status, body } = await request(server) + .get('/album/count') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); + }); + }); + + describe('GET /album/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should return album info for own album', async () => { + const { status, body } = await request(server) + .get(`/album/${user1Albums[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual(user1Albums[0]); + }); + + it('should return album info for shared album', async () => { + const { status, body } = await request(server) + .get(`/album/${user2Albums[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual(user2Albums[0]); + }); + }); + + describe('PUT /album/:id/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should be able to add own asset to own album', async () => { + const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); + const { status, body } = await request(server) + .put(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + }); + + it('should be able to add own asset to shared album', async () => { + const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); + const { status, body } = await request(server) + .put(`/album/${user2Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + }); + }); + describe('PATCH /album/:id', () => { it('should require authentication', async () => { const { status, body } = await request(server) @@ -232,4 +312,107 @@ describe(`${AlbumController.name} (e2e)`, () => { }); }); }); + + describe('DELETE /album/:id/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(server) + .delete(`/album/${user1Albums[0].id}/assets`) + .send({ ids: [user1Asset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should be able to remove own asset from own album', async () => { + const { status, body } = await request(server) + .delete(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); + }); + + it('should be able to remove own asset from shared album', async () => { + const { status, body } = await request(server) + .delete(`/album/${user2Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); + }); + + it('should not be able to remove foreign asset from own album', async () => { + const { status, body } = await request(server) + .delete(`/album/${user2Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ ids: [user1Asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + }); + + it('should not be able to remove foreign asset from foreign album', async () => { + const { status, body } = await request(server) + .delete(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ ids: [user1Asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + }); + }); + + describe('PUT :id/users', () => { + let album: AlbumResponseDto; + + beforeEach(async () => { + album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' }); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server) + .put(`/album/${user1Albums[0].id}/users`) + .send({ sharedUserIds: [] }); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should be able to add user to own album', async () => { + const { status, body } = await request(server) + .put(`/album/${album.id}/users`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ sharedUserIds: [user2.userId] }); + + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] })); + }); + + // it('should not be able to share album with owner', async () => { + // const { status, body } = await request(server) + // .put(`/album/${album.id}/users`) + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ sharedUserIds: [user2.userId] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorStub.badRequest); + // }); + + it('should not be able to add existing user to shared album', async () => { + await request(server) + .put(`/album/${album.id}/users`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ sharedUserIds: [user2.userId] }); + + const { status, body } = await request(server) + .put(`/album/${album.id}/users`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ sharedUserIds: [user2.userId] }); + + expect(status).toBe(400); + expect(body).toEqual({ ...errorStub.badRequest, message: 'User already added' }); + }); + }); }); diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 7e28cc87b3650..ed38fc0ae9790 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -1,12 +1,13 @@ -import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain'; +import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain'; import { AppModule, AssetController } from '@app/immich'; import { AssetEntity, AssetType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; import { randomBytes } from 'crypto'; import request from 'supertest'; -import { errorStub, uuidStub } from '../fixtures'; -import { api, db } from '../test-utils'; const user1Dto = { email: 'user1@immich.app', @@ -22,8 +23,30 @@ const user2Dto = { lastName: 'Test', }; +const makeUploadDto = (options?: { omit: string }): Record => { + const dto: Record = { + 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 => { +const createAsset = ( + repository: IAssetRepository, + loginResponse: LoginResponseDto, + createdAt: Date, +): Promise => { 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 }), + ]), + ); + }); + }); }); diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index d0b1388769677..7791226cd1c0f 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/server/test/e2e/auth.e2e-spec.ts @@ -1,7 +1,8 @@ import { AppModule, AuthController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; +import { api } from '@test/api'; +import { db } from '@test/db'; import { adminSignupStub, changePasswordStub, @@ -11,8 +12,8 @@ import { loginStub, signupResponseStub, uuidStub, -} from '../fixtures'; -import { api, db } from '../test-utils'; +} from '@test/fixtures'; +import request from 'supertest'; const firstName = 'Immich'; const lastName = 'Admin'; @@ -35,8 +36,8 @@ describe(`${AuthController.name} (e2e)`, () => { beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); - const response = await api.adminLogin(server); + await api.authApi.adminSignUp(server); + const response = await api.authApi.adminLogin(server); accessToken = response.accessToken; }); @@ -67,7 +68,7 @@ describe(`${AuthController.name} (e2e)`, () => { } it(`should sign up the admin`, async () => { - await api.adminSignUp(server); + await api.authApi.adminSignUp(server); }); it('should sign up the admin with a local domain', async () => { @@ -87,7 +88,7 @@ describe(`${AuthController.name} (e2e)`, () => { }); it('should not allow a second admin to sign up', async () => { - await api.adminSignUp(server); + await api.authApi.adminSignUp(server); const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); @@ -152,7 +153,7 @@ describe(`${AuthController.name} (e2e)`, () => { }); }); - describe('DELETE /auth/devices/:id', () => { + describe('DELETE /auth/devices', () => { it('should require authentication', async () => { const { status, body } = await request(server).delete(`/auth/devices`); expect(status).toBe(401); @@ -161,15 +162,15 @@ describe(`${AuthController.name} (e2e)`, () => { it('should logout all devices (except the current one)', async () => { for (let i = 0; i < 5; i++) { - await api.adminLogin(server); + await api.authApi.adminLogin(server); } - await expect(api.getAuthDevices(server, accessToken)).resolves.toHaveLength(6); + await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6); const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(204); - await api.validateToken(server, accessToken); + await api.authApi.validateToken(server, accessToken); }); }); @@ -181,7 +182,7 @@ describe(`${AuthController.name} (e2e)`, () => { }); it('should logout a device', async () => { - const [device] = await api.getAuthDevices(server, accessToken); + const [device] = await api.authApi.getAuthDevices(server, accessToken); const { status } = await request(server) .delete(`/auth/devices/${device.id}`) .set('Authorization', `Bearer ${accessToken}`); @@ -244,7 +245,7 @@ describe(`${AuthController.name} (e2e)`, () => { .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(200); - await api.login(server, { email: 'admin@immich.app', password: 'Password1234' }); + await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' }); }); }); diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts index a9f0027819321..06717d855f7d4 100644 --- a/server/test/e2e/oauth.e2e-spec.ts +++ b/server/test/e2e/oauth.e2e-spec.ts @@ -1,9 +1,10 @@ import { AppModule, OAuthController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub } from '@test/fixtures'; import request from 'supertest'; -import { errorStub } from '../fixtures'; -import { api, db } from '../test-utils'; describe(`${OAuthController.name} (e2e)`, () => { let app: INestApplication; @@ -20,7 +21,7 @@ describe(`${OAuthController.name} (e2e)`, () => { beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); + await api.authApi.adminSignUp(server); }); afterAll(async () => { diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts new file mode 100644 index 0000000000000..9283c11bcd87a --- /dev/null +++ b/server/test/e2e/partner.e2e-spec.ts @@ -0,0 +1,146 @@ +import { IPartnerRepository, LoginResponseDto, PartnerDirection } from '@app/domain'; +import { AppModule, PartnerController } from '@app/immich'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub } from '@test/fixtures'; +import request from 'supertest'; + +const user1Dto = { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', +}; + +const user2Dto = { + email: 'user2@immich.app', + password: 'Password123', + firstName: 'User 2', + lastName: 'Test', +}; + +describe(`${PartnerController.name} (e2e)`, () => { + let app: INestApplication; + let server: any; + let loginResponse: LoginResponseDto; + let accessToken: string; + let repository: IPartnerRepository; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + repository = app.get(IPartnerRepository); + }); + + 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' })); + }); + }); +}); diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index 88a5b07fa7914..ae95c6391c270 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -1,16 +1,22 @@ -import { IPersonRepository, LoginResponseDto } from '@app/domain'; +import { IFaceRepository, IPersonRepository, LoginResponseDto } from '@app/domain'; import { AppModule, PersonController } from '@app/immich'; +import { PersonEntity } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; import request from 'supertest'; -import { errorStub, uuidStub } from '../fixtures'; -import { api, db } from '../test-utils'; describe(`${PersonController.name}`, () => { let app: INestApplication; let server: any; let loginResponse: LoginResponseDto; let accessToken: string; + let personRepository: IPersonRepository; + let faceRepository: IFaceRepository; + let visiblePerson: PersonEntity; + let hiddenPerson: PersonEntity; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -19,13 +25,31 @@ describe(`${PersonController.name}`, () => { app = await moduleFixture.createNestApplication().init(); server = app.getHttpServer(); + personRepository = app.get(IPersonRepository); + faceRepository = app.get(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); - 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); - 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); const person = await personRepository.create({ birthDate: new Date('1990-01-01'), ownerId: loginResponse.userId, diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index 2d344996d49d6..e1f6b7ee50876 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain'; import { AppModule, ServerInfoController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub } from '@test/fixtures'; import request from 'supertest'; -import { errorStub } from '../fixtures'; -import { api, db } from '../test-utils'; describe(`${ServerInfoController.name} (e2e)`, () => { let app: INestApplication; @@ -23,8 +24,8 @@ describe(`${ServerInfoController.name} (e2e)`, () => { beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); - loginResponse = await api.adminLogin(server); + await api.authApi.adminSignUp(server); + loginResponse = await api.authApi.adminLogin(server); accessToken = loginResponse.accessToken; }); @@ -116,7 +117,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { it('should only work for admins', async () => { const loginDto = { email: 'test@immich.app', password: 'Immich123' }; await api.userApi.create(server, accessToken, { ...loginDto, firstName: 'test', lastName: 'test' }); - const { accessToken: userAccessToken } = await api.login(server, loginDto); + const { accessToken: userAccessToken } = await api.authApi.login(server, loginDto); const { status, body } = await request(server) .get('/server-info/stats') .set('Authorization', `Bearer ${userAccessToken}`); diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts new file mode 100644 index 0000000000000..9a86080cb000a --- /dev/null +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -0,0 +1,241 @@ +import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain'; +import { AppModule, PartnerController } from '@app/immich'; +import { SharedLinkType } from '@app/infra/entities'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; +import request from 'supertest'; + +const user1Dto = { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', +}; + +describe(`${PartnerController.name} (e2e)`, () => { + let app: INestApplication; + let server: any; + let loginResponse: LoginResponseDto; + let accessToken: string; + let user1: LoginResponseDto; + let album: AlbumResponseDto; + let sharedLink: SharedLinkResponseDto; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await db.reset(); + await api.authApi.adminSignUp(server); + loginResponse = await api.authApi.adminLogin(server); + accessToken = loginResponse.accessToken; + + await api.userApi.create(server, accessToken, user1Dto); + user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); + + album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' }); + sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { + type: SharedLinkType.ALBUM, + albumId: album.id, + }); + }); + + afterAll(async () => { + await db.disconnect(); + await app.close(); + }); + + describe('GET /shared-link', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/shared-link'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should get all shared links created by user', async () => { + const { status, body } = await request(server) + .get('/shared-link') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })]); + }); + + it('should not get shared links created by other users', async () => { + const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + }); + + describe('GET /shared-link/me', () => { + it('should not require admin authentication', async () => { + const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(403); + }); + + it('should get data for correct shared link', async () => { + const { status, body } = await request(server).get('/shared-link/me').query({ key: sharedLink.key }); + + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })); + }); + + it('should return unauthorized for incorrect shared link', async () => { + const { status, body } = await request(server) + .get('/shared-link/me') + .query({ key: sharedLink.key + 'foo' }); + + expect(status).toBe(401); + expect(body).toEqual(expect.objectContaining({ message: 'Invalid share key' })); + }); + }); + + describe('GET /shared-link/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get(`/shared-link/${sharedLink.id}`); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should get shared link by id', async () => { + const { status, body } = await request(server) + .get(`/shared-link/${sharedLink.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM })); + }); + + it('should not get shared link by id if user has not created the link or it does not exist', async () => { + const { status, body } = await request(server) + .get(`/shared-link/${sharedLink.id}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' })); + }); + }); + + describe('POST /shared-link', () => { + it('should require authentication', async () => { + const { status, body } = await request(server) + .post('/shared-link') + .send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound }); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a type and the correspondent asset/album id', async () => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + + it('should require an asset/album id', async () => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ type: SharedLinkType.ALBUM }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' })); + }); + + it('should require a valid asset id', async () => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound }); + + expect(status).toBe(400); + expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' })); + }); + + it('should create a shared link', async () => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ type: SharedLinkType.ALBUM, albumId: album.id }); + + expect(status).toBe(201); + expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId })); + }); + }); + + describe('PATCH /shared-link/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server) + .patch(`/shared-link/${sharedLink.id}`) + .send({ description: 'foo' }); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should fail if invalid link', async () => { + const { status, body } = await request(server) + .patch(`/shared-link/${uuidStub.notFound}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ description: 'foo' }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + + it('should update shared link', async () => { + const { status, body } = await request(server) + .patch(`/shared-link/${sharedLink.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ description: 'foo' }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }), + ); + }); + }); + + describe('DELETE /shared-link/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).delete(`/shared-link/${sharedLink.id}`); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should fail if invalid link', async () => { + const { status, body } = await request(server) + .delete(`/shared-link/${uuidStub.notFound}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + + it('should update shared link', async () => { + const { status } = await request(server) + .delete(`/shared-link/${sharedLink.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + }); + }); +}); diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index aa1cc9d1d7a84..6838febb6a2c0 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/test/e2e/user.e2e-spec.ts @@ -2,9 +2,10 @@ import { LoginResponseDto } from '@app/domain'; import { AppModule, UserController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, userSignupStub, userStub } from '@test/fixtures'; import request from 'supertest'; -import { errorStub, userSignupStub, userStub } from '../fixtures'; -import { api, db } from '../test-utils'; describe(`${UserController.name}`, () => { let app: INestApplication; @@ -23,8 +24,8 @@ describe(`${UserController.name}`, () => { beforeEach(async () => { await db.reset(); - await api.adminSignUp(server); - loginResponse = await api.adminLogin(server); + await api.authApi.adminSignUp(server); + loginResponse = await api.authApi.adminLogin(server); accessToken = loginResponse.accessToken; }); diff --git a/server/test/fixtures/search.stub.ts b/server/test/fixtures/search.stub.ts index f96bddafe825c..eaf03a9f9b3c1 100644 --- a/server/test/fixtures/search.stub.ts +++ b/server/test/fixtures/search.stub.ts @@ -1,4 +1,6 @@ import { SearchResult } from '@app/domain'; +import { AssetEntity } from '@app/infra/entities'; +import { assetStub } from '.'; export const searchStub = { emptyResults: Object.freeze>({ @@ -9,4 +11,13 @@ export const searchStub = { facets: [], distances: [], }), + + withImage: Object.freeze>({ + total: 1, + count: 1, + page: 1, + items: [assetStub.image], + facets: [], + distances: [], + }), }; diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts deleted file mode 100644 index 3028fa07a774c..0000000000000 --- a/server/test/test-utils.ts +++ /dev/null @@ -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;