You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	refactor: asset e2e (#7769)
This commit is contained in:
		
							
								
								
									
										17
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							| @@ -10,23 +10,6 @@ concurrency: | ||||
|   cancel-in-progress: true | ||||
|  | ||||
| jobs: | ||||
|   server-e2e-api: | ||||
|     name: Server (e2e-api) | ||||
|     runs-on: ubuntu-latest | ||||
|     defaults: | ||||
|       run: | ||||
|         working-directory: ./server | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Run npm install | ||||
|         run: npm ci | ||||
|  | ||||
|       - name: Run e2e tests | ||||
|         run: npm run e2e:api | ||||
|  | ||||
|   server-e2e-jobs: | ||||
|     name: Server (e2e-jobs) | ||||
|     runs-on: ubuntu-latest | ||||
|   | ||||
							
								
								
									
										3
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Makefile
									
									
									
									
									
								
							| @@ -19,9 +19,6 @@ pull-stage: | ||||
| server-e2e-jobs: | ||||
| 	docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build | ||||
|  | ||||
| server-e2e-api: | ||||
| 	npm run e2e:api --prefix server | ||||
|  | ||||
| .PHONY: e2e | ||||
| e2e: | ||||
| 	docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans | ||||
|   | ||||
| @@ -2,20 +2,45 @@ import { | ||||
|   AssetFileUploadResponseDto, | ||||
|   AssetResponseDto, | ||||
|   AssetTypeEnum, | ||||
|   LibraryResponseDto, | ||||
|   LoginResponseDto, | ||||
|   SharedLinkType, | ||||
|   TimeBucketSize, | ||||
|   getAllLibraries, | ||||
|   getAssetInfo, | ||||
|   updateAssets, | ||||
| } from '@immich/sdk'; | ||||
| import { exiftool } from 'exiftool-vendored'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { readFile, writeFile } from 'node:fs/promises'; | ||||
| import { basename, join } from 'node:path'; | ||||
| import { Socket } from 'socket.io-client'; | ||||
| import { createUserDto, uuidDto } from 'src/fixtures'; | ||||
| import { makeRandomImage } from 'src/generators'; | ||||
| import { errorDto } from 'src/responses'; | ||||
| import { app, tempDir, testAssetDir, utils } from 'src/utils'; | ||||
| import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils'; | ||||
| import request from 'supertest'; | ||||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||||
|  | ||||
| const makeUploadDto = (options?: { omit: string }): Record<string, any> => { | ||||
|   const dto: Record<string, any> = { | ||||
|     deviceAssetId: 'example-image', | ||||
|     deviceId: 'TEST', | ||||
|     fileCreatedAt: new Date().toISOString(), | ||||
|     fileModifiedAt: new Date().toISOString(), | ||||
|     isFavorite: 'testing', | ||||
|     duration: '0:00:00.000000', | ||||
|   }; | ||||
|  | ||||
|   const omit = options?.omit; | ||||
|   if (omit) { | ||||
|     delete dto[omit]; | ||||
|   } | ||||
|  | ||||
|   return dto; | ||||
| }; | ||||
|  | ||||
| const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; | ||||
|  | ||||
| const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; | ||||
| @@ -35,34 +60,43 @@ const yesterday = today.minus({ days: 1 }); | ||||
|  | ||||
| describe('/asset', () => { | ||||
|   let admin: LoginResponseDto; | ||||
|   let websocket: Socket; | ||||
|  | ||||
|   let user1: LoginResponseDto; | ||||
|   let user2: LoginResponseDto; | ||||
|   let userStats: LoginResponseDto; | ||||
|   let timeBucketUser: LoginResponseDto; | ||||
|   let quotaUser: LoginResponseDto; | ||||
|   let statsUser: LoginResponseDto; | ||||
|   let stackUser: LoginResponseDto; | ||||
|  | ||||
|   let user1Assets: AssetFileUploadResponseDto[]; | ||||
|   let user2Assets: AssetFileUploadResponseDto[]; | ||||
|   let assetLocation: AssetFileUploadResponseDto; | ||||
|   let ws: Socket; | ||||
|   let stackAssets: AssetFileUploadResponseDto[]; | ||||
|   let locationAsset: AssetFileUploadResponseDto; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     await utils.resetDatabase(); | ||||
|     admin = await utils.adminSetup({ onboarding: false }); | ||||
|  | ||||
|     [ws, user1, user2, userStats] = await Promise.all([ | ||||
|     [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ | ||||
|       utils.connectWebsocket(admin.accessToken), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.user1), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.user2), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.user3), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.create('1')), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.create('2')), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.create('stats')), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.userQuota), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), | ||||
|       utils.userSetup(admin.accessToken, createUserDto.create('stack')), | ||||
|     ]); | ||||
|  | ||||
|     // asset location | ||||
|     assetLocation = await utils.createAsset(admin.accessToken, { | ||||
|     locationAsset = await utils.createAsset(admin.accessToken, { | ||||
|       assetData: { | ||||
|         filename: 'thompson-springs.jpg', | ||||
|         bytes: await readFile(locationAssetFilepath), | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id }); | ||||
|     await utils.waitForWebsocketEvent({ event: 'upload', assetId: locationAsset.id }); | ||||
|  | ||||
|     user1Assets = await Promise.all([ | ||||
|       utils.createAsset(user1.accessToken), | ||||
| @@ -80,22 +114,43 @@ describe('/asset', () => { | ||||
|  | ||||
|     user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]); | ||||
|  | ||||
|     await Promise.all([ | ||||
|       utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), | ||||
|       utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), | ||||
|       utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), | ||||
|       utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), | ||||
|     ]); | ||||
|  | ||||
|     for (const asset of [...user1Assets, ...user2Assets]) { | ||||
|       expect(asset.duplicate).toBe(false); | ||||
|     } | ||||
|  | ||||
|     await Promise.all([ | ||||
|       // stats | ||||
|       utils.createAsset(userStats.accessToken), | ||||
|       utils.createAsset(userStats.accessToken, { isFavorite: true }), | ||||
|       utils.createAsset(userStats.accessToken, { isArchived: true }), | ||||
|       utils.createAsset(userStats.accessToken, { | ||||
|       utils.createAsset(statsUser.accessToken), | ||||
|       utils.createAsset(statsUser.accessToken, { isFavorite: true }), | ||||
|       utils.createAsset(statsUser.accessToken, { isArchived: true }), | ||||
|       utils.createAsset(statsUser.accessToken, { | ||||
|         isArchived: true, | ||||
|         isFavorite: true, | ||||
|         assetData: { filename: 'example.mp4' }, | ||||
|       }), | ||||
|     ]); | ||||
|  | ||||
|     // stacks | ||||
|     stackAssets = await Promise.all([ | ||||
|       utils.createAsset(stackUser.accessToken), | ||||
|       utils.createAsset(stackUser.accessToken), | ||||
|       utils.createAsset(stackUser.accessToken), | ||||
|       utils.createAsset(stackUser.accessToken), | ||||
|       utils.createAsset(stackUser.accessToken), | ||||
|     ]); | ||||
|  | ||||
|     await updateAssets( | ||||
|       { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, | ||||
|       { headers: asBearerAuth(stackUser.accessToken) }, | ||||
|     ); | ||||
|  | ||||
|     const person1 = await utils.createPerson(user1.accessToken, { | ||||
|       name: 'Test Person', | ||||
|     }); | ||||
| @@ -106,7 +161,7 @@ describe('/asset', () => { | ||||
|   }, 30_000); | ||||
|  | ||||
|   afterAll(() => { | ||||
|     utils.disconnectWebsocket(ws); | ||||
|     utils.disconnectWebsocket(websocket); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/:id', () => { | ||||
| @@ -193,7 +248,7 @@ describe('/asset', () => { | ||||
|     it('should return stats of all assets', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${userStats.accessToken}`); | ||||
|         .set('Authorization', `Bearer ${statsUser.accessToken}`); | ||||
|  | ||||
|       expect(body).toEqual({ images: 3, videos: 1, total: 4 }); | ||||
|       expect(status).toBe(200); | ||||
| @@ -202,7 +257,7 @@ describe('/asset', () => { | ||||
|     it('should return stats of all favored assets', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${userStats.accessToken}`) | ||||
|         .set('Authorization', `Bearer ${statsUser.accessToken}`) | ||||
|         .query({ isFavorite: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -212,7 +267,7 @@ describe('/asset', () => { | ||||
|     it('should return stats of all archived assets', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${userStats.accessToken}`) | ||||
|         .set('Authorization', `Bearer ${statsUser.accessToken}`) | ||||
|         .query({ isArchived: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -222,7 +277,7 @@ describe('/asset', () => { | ||||
|     it('should return stats of all favored and archived assets', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${userStats.accessToken}`) | ||||
|         .set('Authorization', `Bearer ${statsUser.accessToken}`) | ||||
|         .query({ isFavorite: true, isArchived: true }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -232,7 +287,7 @@ describe('/asset', () => { | ||||
|     it('should return stats of all assets neither favored nor archived', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/statistics') | ||||
|         .set('Authorization', `Bearer ${userStats.accessToken}`) | ||||
|         .set('Authorization', `Bearer ${statsUser.accessToken}`) | ||||
|         .query({ isFavorite: false, isArchived: false }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -488,6 +543,35 @@ describe('/asset', () => { | ||||
|   }); | ||||
|  | ||||
|   describe('POST /asset/upload', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).post(`/asset/upload`); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|       expect(status).toBe(401); | ||||
|     }); | ||||
|  | ||||
|     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 `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(app) | ||||
|           .post('/asset/upload') | ||||
|           .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|           .attach('assetData', makeRandomImage(), 'example.png') | ||||
|           .field(dto); | ||||
|         expect(status).toBe(400); | ||||
|         expect(body).toEqual(errorDto.badRequest()); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     const tests = [ | ||||
|       { | ||||
|         input: 'formats/jpg/el_torcal_rocks.jpg', | ||||
| @@ -601,7 +685,7 @@ describe('/asset', () => { | ||||
|     ]; | ||||
|  | ||||
|     for (const { input, expected } of tests) { | ||||
|       it(`should generate a thumbnail for ${input}`, async () => { | ||||
|       it(`should upload and generate a thumbnail for ${input}`, async () => { | ||||
|         const filepath = join(testAssetDir, input); | ||||
|         const { id, duplicate } = await utils.createAsset(admin.accessToken, { | ||||
|           assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, | ||||
| @@ -631,6 +715,57 @@ describe('/asset', () => { | ||||
|       expect(duplicate).toBe(true); | ||||
|     }); | ||||
|  | ||||
|     it("should not upload to another user's library", async () => { | ||||
|       const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); | ||||
|       const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto; | ||||
|  | ||||
|       const { body, status } = await request(app) | ||||
|         .post('/asset/upload') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .field('libraryId', library.id) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'e2e') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .field('duration', '0:00:00.000000') | ||||
|         .attach('assetData', makeRandomImage(), 'example.png'); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access')); | ||||
|     }); | ||||
|  | ||||
|     it('should update the used quota', async () => { | ||||
|       const { body, status } = await request(app) | ||||
|         .post('/asset/upload') | ||||
|         .set('Authorization', `Bearer ${quotaUser.accessToken}`) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'e2e') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .attach('assetData', makeRandomImage(), 'example.jpg'); | ||||
|  | ||||
|       expect(body).toEqual({ id: expect.any(String), duplicate: false }); | ||||
|       expect(status).toBe(201); | ||||
|  | ||||
|       const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`); | ||||
|  | ||||
|       expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); | ||||
|     }); | ||||
|  | ||||
|     it('should not upload an asset if it would exceed the quota', async () => { | ||||
|       const { body, status } = await request(app) | ||||
|         .post('/asset/upload') | ||||
|         .set('Authorization', `Bearer ${quotaUser.accessToken}`) | ||||
|         .field('deviceAssetId', 'example-image') | ||||
|         .field('deviceId', 'e2e') | ||||
|         .field('fileCreatedAt', new Date().toISOString()) | ||||
|         .field('fileModifiedAt', new Date().toISOString()) | ||||
|         .attach('assetData', randomBytes(2014), 'example.jpg'); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); | ||||
|     }); | ||||
|  | ||||
|     // These hashes were created by copying the image files to a Samsung phone, | ||||
|     // exporting the video from Samsung's stock Gallery app, and hashing them locally. | ||||
|     // This ensures that immich+exiftool are extracting the videos the same way Samsung does. | ||||
| @@ -675,7 +810,7 @@ describe('/asset', () => { | ||||
|  | ||||
|   describe('GET /asset/thumbnail/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); | ||||
|       const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
| @@ -683,12 +818,12 @@ describe('/asset', () => { | ||||
|  | ||||
|     it('should not include gps data for webp thumbnails', async () => { | ||||
|       const { status, body, type } = await request(app) | ||||
|         .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) | ||||
|         .get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`) | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|  | ||||
|       await utils.waitForWebsocketEvent({ | ||||
|         event: 'upload', | ||||
|         assetId: assetLocation.id, | ||||
|         assetId: locationAsset.id, | ||||
|       }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -702,7 +837,7 @@ describe('/asset', () => { | ||||
|  | ||||
|     it('should not include gps data for jpeg thumbnails', async () => { | ||||
|       const { status, body, type } = await request(app) | ||||
|         .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) | ||||
|         .get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`) | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
| @@ -717,7 +852,7 @@ describe('/asset', () => { | ||||
|  | ||||
|   describe('GET /asset/file/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); | ||||
|       const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
| @@ -725,14 +860,14 @@ describe('/asset', () => { | ||||
|  | ||||
|     it('should download the original', async () => { | ||||
|       const { status, body, type } = await request(app) | ||||
|         .get(`/asset/file/${assetLocation.id}`) | ||||
|         .get(`/asset/file/${locationAsset.id}`) | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toBeDefined(); | ||||
|       expect(type).toBe('image/jpeg'); | ||||
|  | ||||
|       const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id); | ||||
|       const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id); | ||||
|  | ||||
|       const original = await readFile(locationAssetFilepath); | ||||
|       const originalChecksum = utils.sha1(original); | ||||
| @@ -742,4 +877,376 @@ describe('/asset', () => { | ||||
|       expect(downloadChecksum).toBe(asset.checksum); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/map-marker', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).get('/asset/map-marker'); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     // TODO archive one of these assets | ||||
|     it('should get map markers for all non-archived assets', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/map-marker') | ||||
|         .query({ isArchived: false }) | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toHaveLength(2); | ||||
|       expect(body).toEqual([ | ||||
|         { | ||||
|           city: 'Palisade', | ||||
|           country: 'United States of America', | ||||
|           id: expect.any(String), | ||||
|           lat: expect.closeTo(39.115), | ||||
|           lon: expect.closeTo(-108.400_968), | ||||
|           state: 'Mesa County, Colorado', | ||||
|         }, | ||||
|         { | ||||
|           city: 'Ralston', | ||||
|           country: 'United States of America', | ||||
|           id: expect.any(String), | ||||
|           lat: expect.closeTo(41.2203), | ||||
|           lon: expect.closeTo(-96.071_625), | ||||
|           state: 'Douglas County, Nebraska', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     // TODO archive one of these assets | ||||
|     it('should get all map markers', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/map-marker') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([ | ||||
|         { | ||||
|           city: 'Palisade', | ||||
|           country: 'United States of America', | ||||
|           id: expect.any(String), | ||||
|           lat: expect.closeTo(39.115), | ||||
|           lon: expect.closeTo(-108.400_968), | ||||
|           state: 'Mesa County, Colorado', | ||||
|         }, | ||||
|         { | ||||
|           city: 'Ralston', | ||||
|           country: 'United States of America', | ||||
|           id: expect.any(String), | ||||
|           lat: expect.closeTo(41.2203), | ||||
|           lon: expect.closeTo(-96.071_625), | ||||
|           state: 'Douglas County, Nebraska', | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/time-buckets', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should get time buckets by month', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, | ||||
|           { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should not allow access for unrelated shared links', async () => { | ||||
|       const sharedLink = await utils.createSharedLink(user1.accessToken, { | ||||
|         type: SharedLinkType.Individual, | ||||
|         assetIds: user1Assets.map(({ id }) => id), | ||||
|       }); | ||||
|  | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .query({ key: sharedLink.key, size: TimeBucketSize.Month }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.noPermission); | ||||
|     }); | ||||
|  | ||||
|     it('should get time buckets by day', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Day }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([ | ||||
|         { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, | ||||
|         { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, | ||||
|         { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset/time-bucket', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).get('/asset/time-bucket').query({ | ||||
|         size: TimeBucketSize.Month, | ||||
|         timeBucket: '1900-01-01T00:00:00.000Z', | ||||
|       }); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should handle 5 digit years', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/time-bucket') | ||||
|         .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([]); | ||||
|     }); | ||||
|  | ||||
|     // TODO enable date string validation while still accepting 5 digit years | ||||
|     // it('should fail if time bucket is invalid', async () => { | ||||
|     //   const { status, body } = await request(app) | ||||
|     //     .get('/asset/time-bucket') | ||||
|     //     .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|     //     .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); | ||||
|  | ||||
|     //   expect(status).toBe(400); | ||||
|     //   expect(body).toEqual(errorDto.badRequest); | ||||
|     // }); | ||||
|  | ||||
|     it('should return time bucket', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .get('/asset/time-bucket') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual([]); | ||||
|     }); | ||||
|  | ||||
|     it('should return error if time bucket is requested with partners asset and archived', async () => { | ||||
|       const req1 = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); | ||||
|  | ||||
|       expect(req1.status).toBe(400); | ||||
|       expect(req1.body).toEqual(errorDto.badRequest()); | ||||
|  | ||||
|       const req2 = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); | ||||
|  | ||||
|       expect(req2.status).toBe(400); | ||||
|       expect(req2.body).toEqual(errorDto.badRequest()); | ||||
|     }); | ||||
|  | ||||
|     it('should return error if time bucket is requested with partners asset and favorite', async () => { | ||||
|       const req1 = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); | ||||
|  | ||||
|       expect(req1.status).toBe(400); | ||||
|       expect(req1.body).toEqual(errorDto.badRequest()); | ||||
|  | ||||
|       const req2 = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); | ||||
|  | ||||
|       expect(req2.status).toBe(400); | ||||
|       expect(req2.body).toEqual(errorDto.badRequest()); | ||||
|     }); | ||||
|  | ||||
|     it('should return error if time bucket is requested with partners asset and trash', async () => { | ||||
|       const req = await request(app) | ||||
|         .get('/asset/time-buckets') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); | ||||
|  | ||||
|       expect(req.status).toBe(400); | ||||
|       expect(req.body).toEqual(errorDto.badRequest()); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('GET /asset', () => { | ||||
|     it('should return stack data', async () => { | ||||
|       const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); | ||||
|  | ||||
|       const stack = body.find((asset: AssetResponseDto) => asset.id === stackAssets[0].id); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|       expect(stack).toEqual( | ||||
|         expect.objectContaining({ | ||||
|           stackCount: 3, | ||||
|           stack: | ||||
|             // Response includes children at the root level | ||||
|             expect.arrayContaining([ | ||||
|               expect.objectContaining({ id: stackAssets[1].id }), | ||||
|               expect.objectContaining({ id: stackAssets[2].id }), | ||||
|             ]), | ||||
|         }), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT /asset', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).put('/asset'); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should require a valid parent id', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); | ||||
|     }); | ||||
|  | ||||
|     it('should require access to the parent', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.noPermission); | ||||
|     }); | ||||
|  | ||||
|     it('should add stack children', async () => { | ||||
|       const { status } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${stackUser.accessToken}`) | ||||
|         .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); | ||||
|  | ||||
|       expect(status).toBe(204); | ||||
|  | ||||
|       const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); | ||||
|       expect(asset.stack).not.toBeUndefined(); | ||||
|       expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); | ||||
|     }); | ||||
|  | ||||
|     it('should remove stack children', async () => { | ||||
|       const { status } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${stackUser.accessToken}`) | ||||
|         .send({ removeParent: true, ids: [stackAssets[1].id] }); | ||||
|  | ||||
|       expect(status).toBe(204); | ||||
|  | ||||
|       const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); | ||||
|       expect(asset.stack).not.toBeUndefined(); | ||||
|       expect(asset.stack).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ id: stackAssets[2].id }), | ||||
|           expect.objectContaining({ id: stackAssets[3].id }), | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should remove all stack children', async () => { | ||||
|       const { status } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${stackUser.accessToken}`) | ||||
|         .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); | ||||
|  | ||||
|       expect(status).toBe(204); | ||||
|  | ||||
|       const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); | ||||
|       expect(asset.stack).toBeUndefined(); | ||||
|     }); | ||||
|  | ||||
|     it('should merge stack children', async () => { | ||||
|       // create stack after previous test removed stack children | ||||
|       await updateAssets( | ||||
|         { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, | ||||
|         { headers: asBearerAuth(stackUser.accessToken) }, | ||||
|       ); | ||||
|  | ||||
|       const { status } = await request(app) | ||||
|         .put('/asset') | ||||
|         .set('Authorization', `Bearer ${stackUser.accessToken}`) | ||||
|         .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); | ||||
|  | ||||
|       expect(status).toBe(204); | ||||
|  | ||||
|       const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); | ||||
|       expect(asset.stack).not.toBeUndefined(); | ||||
|       expect(asset.stack).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ id: stackAssets[0].id }), | ||||
|           expect.objectContaining({ id: stackAssets[1].id }), | ||||
|           expect.objectContaining({ id: stackAssets[2].id }), | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PUT /asset/stack/parent', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(app).put('/asset/stack/parent'); | ||||
|  | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should require a valid id', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .put('/asset/stack/parent') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.badRequest()); | ||||
|     }); | ||||
|  | ||||
|     it('should require access', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .put('/asset/stack/parent') | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); | ||||
|  | ||||
|       expect(status).toBe(400); | ||||
|       expect(body).toEqual(errorDto.noPermission); | ||||
|     }); | ||||
|  | ||||
|     it('should make old parent child of new parent', async () => { | ||||
|       const { status } = await request(app) | ||||
|         .put('/asset/stack/parent') | ||||
|         .set('Authorization', `Bearer ${stackUser.accessToken}`) | ||||
|         .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); | ||||
|  | ||||
|       expect(status).toBe(200); | ||||
|  | ||||
|       const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); | ||||
|  | ||||
|       // new parent | ||||
|       expect(asset.stack).not.toBeUndefined(); | ||||
|       expect(asset.stack).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ id: stackAssets[1].id }), | ||||
|           expect.objectContaining({ id: stackAssets[2].id }), | ||||
|           expect.objectContaining({ id: stackAssets[3].id }), | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,52 +1,76 @@ | ||||
| import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; | ||||
| import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { readFile } from 'node:fs/promises'; | ||||
| import { join } from 'node:path'; | ||||
| import { Socket } from 'socket.io-client'; | ||||
| import { errorDto } from 'src/responses'; | ||||
| import { app, testAssetDir, utils } from 'src/utils'; | ||||
| import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; | ||||
| import request from 'supertest'; | ||||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||||
|  | ||||
| const albums = { total: 0, count: 0, items: [], facets: [] }; | ||||
| const today = DateTime.now(); | ||||
|  | ||||
| describe('/search', () => { | ||||
|   let admin: LoginResponseDto; | ||||
|   let websocket: Socket; | ||||
|  | ||||
|   let assetFalcon: AssetFileUploadResponseDto; | ||||
|   let assetDenali: AssetFileUploadResponseDto; | ||||
|   let websocket: Socket; | ||||
|   let assetCyclamen: AssetFileUploadResponseDto; | ||||
|   let assetNotocactus: AssetFileUploadResponseDto; | ||||
|   let assetSilver: AssetFileUploadResponseDto; | ||||
|   // let assetDensity: AssetFileUploadResponseDto; | ||||
|   // let assetPhiladelphia: AssetFileUploadResponseDto; | ||||
|   // let assetOrychophragmus: AssetFileUploadResponseDto; | ||||
|   // let assetRidge: AssetFileUploadResponseDto; | ||||
|   // let assetPolemonium: AssetFileUploadResponseDto; | ||||
|   // let assetWood: AssetFileUploadResponseDto; | ||||
|   let assetHeic: AssetFileUploadResponseDto; | ||||
|   let assetRocks: AssetFileUploadResponseDto; | ||||
|   let assetOneJpg6: AssetFileUploadResponseDto; | ||||
|   let assetOneHeic6: AssetFileUploadResponseDto; | ||||
|   let assetOneJpg5: AssetFileUploadResponseDto; | ||||
|   let assetGlarus: AssetFileUploadResponseDto; | ||||
|   let assetSprings: AssetFileUploadResponseDto; | ||||
|   let assetLast: AssetFileUploadResponseDto; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     await utils.resetDatabase(); | ||||
|     admin = await utils.adminSetup(); | ||||
|     websocket = await utils.connectWebsocket(admin.accessToken); | ||||
|  | ||||
|     const files: string[] = [ | ||||
|       '/albums/nature/prairie_falcon.jpg', | ||||
|       '/formats/webp/denali.webp', | ||||
|       '/formats/raw/Nikon/D700/philadelphia.nef', | ||||
|       '/albums/nature/orychophragmus_violaceus.jpg', | ||||
|       '/albums/nature/notocactus_minimus.jpg', | ||||
|       '/albums/nature/silver_fir.jpg', | ||||
|       '/albums/nature/tanners_ridge.jpg', | ||||
|       '/albums/nature/cyclamen_persicum.jpg', | ||||
|       '/albums/nature/polemonium_reptans.jpg', | ||||
|       '/albums/nature/wood_anemones.jpg', | ||||
|       '/formats/heic/IMG_2682.heic', | ||||
|       '/formats/jpg/el_torcal_rocks.jpg', | ||||
|       '/formats/png/density_plot.png', | ||||
|       '/formats/motionphoto/Samsung One UI 6.jpg', | ||||
|       '/formats/motionphoto/Samsung One UI 6.heic', | ||||
|       '/formats/motionphoto/Samsung One UI 5.jpg', | ||||
|       '/formats/raw/Nikon/D80/glarus.nef', | ||||
|       '/metadata/gps-position/thompson-springs.jpg', | ||||
|     const files = [ | ||||
|       { filename: '/albums/nature/prairie_falcon.jpg' }, | ||||
|       { filename: '/formats/webp/denali.webp' }, | ||||
|       { filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } }, | ||||
|       { filename: '/albums/nature/notocactus_minimus.jpg' }, | ||||
|       { filename: '/albums/nature/silver_fir.jpg' }, | ||||
|       { filename: '/formats/heic/IMG_2682.heic' }, | ||||
|       { filename: '/formats/jpg/el_torcal_rocks.jpg' }, | ||||
|       { filename: '/formats/motionphoto/Samsung One UI 6.jpg' }, | ||||
|       { filename: '/formats/motionphoto/Samsung One UI 6.heic' }, | ||||
|       { filename: '/formats/motionphoto/Samsung One UI 5.jpg' }, | ||||
|       { filename: '/formats/raw/Nikon/D80/glarus.nef', dto: { isReadOnly: true } }, | ||||
|       { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } }, | ||||
|  | ||||
|       // used for search suggestions | ||||
|       { filename: '/formats/png/density_plot.png' }, | ||||
|       { filename: '/formats/raw/Nikon/D700/philadelphia.nef' }, | ||||
|       { filename: '/albums/nature/orychophragmus_violaceus.jpg' }, | ||||
|       { filename: '/albums/nature/tanners_ridge.jpg' }, | ||||
|       { filename: '/albums/nature/polemonium_reptans.jpg' }, | ||||
|  | ||||
|       // last asset | ||||
|       { filename: '/albums/nature/wood_anemones.jpg' }, | ||||
|     ]; | ||||
|     const assets: AssetFileUploadResponseDto[] = []; | ||||
|     for (const filename of files) { | ||||
|     for (const { filename, dto } of files) { | ||||
|       const bytes = await readFile(join(testAssetDir, filename)); | ||||
|       assets.push( | ||||
|         await utils.createAsset(admin.accessToken, { | ||||
|           deviceAssetId: `test-${filename}`, | ||||
|           assetData: { bytes, filename }, | ||||
|           ...dto, | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
| @@ -55,7 +79,30 @@ describe('/search', () => { | ||||
|       await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); | ||||
|     } | ||||
|  | ||||
|     [assetFalcon, assetDenali] = assets; | ||||
|     [ | ||||
|       assetFalcon, | ||||
|       assetDenali, | ||||
|       assetCyclamen, | ||||
|       assetNotocactus, | ||||
|       assetSilver, | ||||
|       assetHeic, | ||||
|       assetRocks, | ||||
|       assetOneJpg6, | ||||
|       assetOneHeic6, | ||||
|       assetOneJpg5, | ||||
|       assetGlarus, | ||||
|       assetSprings, | ||||
|       // assetDensity, | ||||
|       // assetPhiladelphia, | ||||
|       // assetOrychophragmus, | ||||
|       // assetRidge, | ||||
|       // assetPolemonium, | ||||
|       // assetWood, | ||||
|     ] = assets; | ||||
|  | ||||
|     assetLast = assets.at(-1) as AssetFileUploadResponseDto; | ||||
|  | ||||
|     await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
| @@ -69,44 +116,226 @@ describe('/search', () => { | ||||
|       expect(body).toEqual(errorDto.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should search by camera make', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/search/metadata') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ make: 'Canon' }); | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         albums, | ||||
|         assets: { | ||||
|           count: 2, | ||||
|           items: expect.arrayContaining([ | ||||
|             expect.objectContaining({ id: assetDenali.id }), | ||||
|             expect.objectContaining({ id: assetFalcon.id }), | ||||
|           ]), | ||||
|           facets: [], | ||||
|           nextPage: null, | ||||
|           total: 2, | ||||
|         }, | ||||
|       }); | ||||
|     }); | ||||
|     const badTests = [ | ||||
|       { | ||||
|         should: 'should reject page as a string', | ||||
|         dto: { page: 'abc' }, | ||||
|         expected: ['page must not be less than 1', 'page must be an integer number'], | ||||
|       }, | ||||
|       { | ||||
|         should: 'should reject page as a decimal', | ||||
|         dto: { page: 1.5 }, | ||||
|         expected: ['page must be an integer number'], | ||||
|       }, | ||||
|       { | ||||
|         should: 'should reject page as a negative number', | ||||
|         dto: { page: -10 }, | ||||
|         expected: ['page must not be less than 1'], | ||||
|       }, | ||||
|       { | ||||
|         should: 'should reject page as 0', | ||||
|         dto: { page: 0 }, | ||||
|         expected: ['page must not be less than 1'], | ||||
|       }, | ||||
|       { | ||||
|         should: 'should reject size as a string', | ||||
|         dto: { size: 'abc' }, | ||||
|         expected: [ | ||||
|           'size must not be greater than 1000', | ||||
|           'size must not be less than 1', | ||||
|           'size must be an integer number', | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         should: 'should reject an invalid size', | ||||
|         dto: { size: -1.5 }, | ||||
|         expected: ['size must not be less than 1', 'size must be an integer number'], | ||||
|       }, | ||||
|       ...[ | ||||
|         'isArchived', | ||||
|         'isFavorite', | ||||
|         'isReadOnly', | ||||
|         'isExternal', | ||||
|         'isEncoded', | ||||
|         'isMotion', | ||||
|         'isOffline', | ||||
|         'isVisible', | ||||
|       ].map((value) => ({ | ||||
|         should: `should reject ${value} not a boolean`, | ||||
|         dto: { [value]: 'immich' }, | ||||
|         expected: [`${value} must be a boolean value`], | ||||
|       })), | ||||
|     ]; | ||||
|  | ||||
|     it('should search by camera model', async () => { | ||||
|       const { status, body } = await request(app) | ||||
|         .post('/search/metadata') | ||||
|         .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|         .send({ model: 'Canon EOS 7D' }); | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         albums, | ||||
|         assets: { | ||||
|           count: 1, | ||||
|           items: [expect.objectContaining({ id: assetDenali.id })], | ||||
|           facets: [], | ||||
|           nextPage: null, | ||||
|           total: 1, | ||||
|         }, | ||||
|     for (const { should, dto, expected } of badTests) { | ||||
|       it(should, async () => { | ||||
|         const { status, body } = await request(app) | ||||
|           .post('/search/metadata') | ||||
|           .set('Authorization', `Bearer ${admin.accessToken}`) | ||||
|           .send(dto); | ||||
|         expect(status).toBe(400); | ||||
|         expect(body).toEqual(errorDto.badRequest(expected)); | ||||
|       }); | ||||
|     }); | ||||
|     } | ||||
|  | ||||
|     const searchTests = [ | ||||
|       { | ||||
|         should: 'should get my assets', | ||||
|         deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should sort my assets in reverse', | ||||
|         deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should support pagination', | ||||
|         deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by checksum (base64)', | ||||
|         deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by checksum (hex)', | ||||
|         deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }), | ||||
|       }, | ||||
|       { should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) }, | ||||
|       { | ||||
|         should: 'should search by isFavorite (true)', | ||||
|         deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by isFavorite (false)', | ||||
|         deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by isArchived (true)', | ||||
|         deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by isArchived (false)', | ||||
|         deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by isReadOnly (true)', | ||||
|         deferred: () => ({ dto: { isReadOnly: true }, assets: [assetGlarus] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by isReadOnly (false)', | ||||
|         deferred: () => ({ dto: { size: 1, isReadOnly: false }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by type (image)', | ||||
|         deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by type (video)', | ||||
|         deferred: () => ({ | ||||
|           dto: { type: 'VIDEO' }, | ||||
|           assets: [ | ||||
|             // the three live motion photos | ||||
|             { id: expect.any(String) }, | ||||
|             { id: expect.any(String) }, | ||||
|             { id: expect.any(String) }, | ||||
|           ], | ||||
|         }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by trashedBefore', | ||||
|         deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by trashedBefore (no results)', | ||||
|         deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by trashedAfter', | ||||
|         deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by trashedAfter (no results)', | ||||
|         deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by takenBefore', | ||||
|         deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by takenBefore (no results)', | ||||
|         deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by takenAfter', | ||||
|         deferred: () => ({ | ||||
|           dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() }, | ||||
|           assets: [assetLast], | ||||
|         }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by takenAfter (no results)', | ||||
|         deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), | ||||
|       }, | ||||
|       //   { | ||||
|       //     should: 'should search by originalPath', | ||||
|       //     deferred: () => ({ | ||||
|       //       dto: { originalPath: asset1.originalPath }, | ||||
|       //       assets: [asset1], | ||||
|       //     }), | ||||
|       //   }, | ||||
|       { | ||||
|         should: 'should search by originalFilename', | ||||
|         deferred: () => ({ | ||||
|           dto: { originalFileName: 'rocks' }, | ||||
|           assets: [assetRocks], | ||||
|         }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by originalFilename with spaces', | ||||
|         deferred: () => ({ | ||||
|           dto: { originalFileName: 'Samsung One', type: 'IMAGE' }, | ||||
|           assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6], | ||||
|         }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by city', | ||||
|         deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by state', | ||||
|         deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by country', | ||||
|         deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by make', | ||||
|         deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), | ||||
|       }, | ||||
|       { | ||||
|         should: 'should search by model', | ||||
|         deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     for (const { should, deferred } of searchTests) { | ||||
|       it(should, async () => { | ||||
|         const { assets, dto } = deferred(); | ||||
|         const { status, body } = await request(app) | ||||
|           .post('/search/metadata') | ||||
|           .send(dto) | ||||
|           .set('Authorization', `Bearer ${admin.accessToken}`); | ||||
|         console.dir({ status, body }, { depth: 10 }); | ||||
|         expect(status).toBe(200); | ||||
|         expect(body.assets).toBeDefined(); | ||||
|         expect(Array.isArray(body.assets.items)).toBe(true); | ||||
|         console.log({ assets: body.assets.items }); | ||||
|         for (const [i, asset] of assets.entries()) { | ||||
|           expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id })); | ||||
|         } | ||||
|         expect(body.assets.items).toHaveLength(assets.length); | ||||
|       }); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   describe('POST /search/smart', () => { | ||||
|   | ||||
| @@ -21,6 +21,13 @@ export const signupDto = { | ||||
| }; | ||||
|  | ||||
| export const createUserDto = { | ||||
|   create(key: string) { | ||||
|     return { | ||||
|       email: `${key}@immich.cloud`, | ||||
|       name: `User ${key}`, | ||||
|       password: `password-${key}`, | ||||
|     }; | ||||
|   }, | ||||
|   user1: { | ||||
|     email: 'user1@immich.cloud', | ||||
|     name: 'User 1', | ||||
| @@ -36,6 +43,12 @@ export const createUserDto = { | ||||
|     name: 'User 3', | ||||
|     password: 'password123', | ||||
|   }, | ||||
|   userQuota: { | ||||
|     email: 'user-quota@immich.cloud', | ||||
|     name: 'User Quota', | ||||
|     password: 'password-quota', | ||||
|     quotaSizeInBytes: 512, | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const userDto = { | ||||
|   | ||||
| @@ -104,6 +104,8 @@ export const utils = { | ||||
|       } | ||||
|  | ||||
|       tables = tables || [ | ||||
|         // TODO e2e test for deleting a stack, since it is quite complex | ||||
|         'asset_stack', | ||||
|         'libraries', | ||||
|         'shared_links', | ||||
|         'person', | ||||
| @@ -117,9 +119,17 @@ export const utils = { | ||||
|         'system_metadata', | ||||
|       ]; | ||||
|  | ||||
|       for (const table of tables) { | ||||
|         await client.query(`DELETE FROM ${table} CASCADE;`); | ||||
|       const sql: string[] = []; | ||||
|  | ||||
|       if (tables.includes('asset_stack')) { | ||||
|         sql.push('UPDATE "assets" SET "stackId" = NULL;'); | ||||
|       } | ||||
|  | ||||
|       for (const table of tables) { | ||||
|         sql.push(`DELETE FROM ${table} CASCADE;`); | ||||
|       } | ||||
|  | ||||
|       await client.query(sql.join('\n')); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to reset database', error); | ||||
|       throw error; | ||||
|   | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "moduleFileExtensions": ["js", "json", "ts"], | ||||
|   "modulePaths": ["<rootDir>"], | ||||
|   "rootDir": "../..", | ||||
|   "globalSetup": "<rootDir>/e2e/api/setup.ts", | ||||
|   "testEnvironment": "node", | ||||
|   "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], | ||||
|   "transform": { | ||||
|     "^.+\\.(t|j)s$": "ts-jest" | ||||
|   }, | ||||
|   "collectCoverageFrom": [ | ||||
|     "<rootDir>/src/**/*.(t|j)s", | ||||
|     "!<rootDir>/src/**/*.spec.(t|s)s", | ||||
|     "!<rootDir>/src/infra/migrations/**" | ||||
|   ], | ||||
|   "coverageDirectory": "./coverage", | ||||
|   "moduleNameMapper": { | ||||
|     "^@test(|/.*)$": "<rootDir>/test/$1", | ||||
|     "^@app/immich(|/.*)$": "<rootDir>/src/immich/$1", | ||||
|     "^@app/infra(|/.*)$": "<rootDir>/src/infra/$1", | ||||
|     "^@app/domain(|/.*)$": "<rootDir>/src/domain/$1" | ||||
|   } | ||||
| } | ||||
| @@ -1,29 +0,0 @@ | ||||
| import { PostgreSqlContainer } from '@testcontainers/postgresql'; | ||||
| import path from 'node:path'; | ||||
|  | ||||
| export default async () => { | ||||
|   let IMMICH_TEST_ASSET_PATH: string = ''; | ||||
|  | ||||
|   if (process.env.IMMICH_TEST_ASSET_PATH === undefined) { | ||||
|     IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`); | ||||
|     process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH; | ||||
|   } else { | ||||
|     IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; | ||||
|   } | ||||
|  | ||||
|   const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') | ||||
|     .withDatabase('immich') | ||||
|     .withUsername('postgres') | ||||
|     .withPassword('postgres') | ||||
|     .withReuse() | ||||
|     .withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so']) | ||||
|     .start(); | ||||
|  | ||||
|   process.env.DB_URL = pg.getConnectionUri(); | ||||
|   process.env.NODE_ENV = 'development'; | ||||
|   process.env.TZ = 'Z'; | ||||
|  | ||||
|   if (process.env.LOG_LEVEL === undefined) { | ||||
|     process.env.LOG_LEVEL = 'fatal'; | ||||
|   } | ||||
| }; | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,118 +0,0 @@ | ||||
| import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain'; | ||||
| import { AppModule } from '@app/immich'; | ||||
| import { InfraModule, InfraTestModule, dataSource } from '@app/infra'; | ||||
| import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test } from '@nestjs/testing'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { EntityTarget, ObjectLiteral } from 'typeorm'; | ||||
| import { AppService } from '../../src/microservices/app.service'; | ||||
| import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test'; | ||||
|  | ||||
| export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); | ||||
| export const yesterday = today.minus({ days: 1 }); | ||||
|  | ||||
| export interface ResetOptions { | ||||
|   entities?: EntityTarget<ObjectLiteral>[]; | ||||
| } | ||||
| export const db = { | ||||
|   reset: async (options?: ResetOptions) => { | ||||
|     if (!dataSource.isInitialized) { | ||||
|       await dataSource.initialize(); | ||||
|     } | ||||
|     await dataSource.transaction(async (em) => { | ||||
|       const entities = options?.entities || []; | ||||
|       const tableNames = | ||||
|         entities.length > 0 | ||||
|           ? entities.map((entity) => em.getRepository(entity).metadata.tableName) | ||||
|           : dataSource.entityMetadatas | ||||
|               .map((entity) => entity.tableName) | ||||
|               .filter((tableName) => !tableName.startsWith('geodata')); | ||||
|  | ||||
|       if (tableNames.includes('asset_stack')) { | ||||
|         await em.query(`DELETE FROM "asset_stack" CASCADE;`); | ||||
|       } | ||||
|       let deleteUsers = false; | ||||
|       for (const tableName of tableNames) { | ||||
|         if (tableName === 'users') { | ||||
|           deleteUsers = true; | ||||
|           continue; | ||||
|         } | ||||
|         await em.query(`DELETE FROM ${tableName} CASCADE;`); | ||||
|       } | ||||
|       if (deleteUsers) { | ||||
|         await em.query(`DELETE FROM "users" CASCADE;`); | ||||
|       } | ||||
|     }); | ||||
|   }, | ||||
|   disconnect: async () => { | ||||
|     if (dataSource.isInitialized) { | ||||
|       await dataSource.destroy(); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| let app: INestApplication; | ||||
|  | ||||
| export const testApp = { | ||||
|   create: async (): Promise<INestApplication> => { | ||||
|     const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) | ||||
|       .overrideModule(InfraModule) | ||||
|       .useModule(InfraTestModule) | ||||
|       .overrideProvider(IJobRepository) | ||||
|       .useValue(newJobRepositoryMock()) | ||||
|       .overrideProvider(IMetadataRepository) | ||||
|       .useValue(newMetadataRepositoryMock()) | ||||
|       .compile(); | ||||
|  | ||||
|     app = await moduleFixture.createNestApplication().init(); | ||||
|     await app.get(AppService).init(); | ||||
|  | ||||
|     return app; | ||||
|   }, | ||||
|   reset: async (options?: ResetOptions) => { | ||||
|     await db.reset(options); | ||||
|   }, | ||||
|   teardown: async () => { | ||||
|     if (app) { | ||||
|       await app.get(AppService).teardown(); | ||||
|       await app.close(); | ||||
|     } | ||||
|     await db.disconnect(); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| function randomDate(start: Date, end: Date): Date { | ||||
|   return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); | ||||
| } | ||||
|  | ||||
| let assetCount = 0; | ||||
| export function generateAsset( | ||||
|   userId: string, | ||||
|   libraries: LibraryResponseDto[], | ||||
|   other: Partial<AssetEntity> = {}, | ||||
| ): AssetCreate { | ||||
|   const id = assetCount++; | ||||
|   const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other; | ||||
|  | ||||
|   return { | ||||
|     createdAt: today.toJSDate(), | ||||
|     updatedAt: today.toJSDate(), | ||||
|     ownerId: userId, | ||||
|     checksum: randomBytes(20), | ||||
|     originalPath: `/tests/test_${id}`, | ||||
|     deviceAssetId: `test_${id}`, | ||||
|     deviceId: 'e2e-test', | ||||
|     libraryId: ( | ||||
|       libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto | ||||
|     ).id, | ||||
|     isVisible: true, | ||||
|     fileCreatedAt, | ||||
|     fileModifiedAt: new Date(), | ||||
|     localDateTime: fileCreatedAt, | ||||
|     type: AssetType.IMAGE, | ||||
|     originalFileName: `test_${id}`, | ||||
|     ...other, | ||||
|   }; | ||||
| } | ||||
| @@ -1,77 +1,10 @@ | ||||
| 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 'node:crypto'; | ||||
| import request from 'supertest'; | ||||
|  | ||||
| type UploadDto = Partial<CreateAssetDto> & { content?: Buffer; filename?: string }; | ||||
|  | ||||
| const asset = { | ||||
|   deviceAssetId: 'test-1', | ||||
|   deviceId: 'test', | ||||
|   fileCreatedAt: new Date(), | ||||
|   fileModifiedAt: new Date(), | ||||
| }; | ||||
|  | ||||
| export const assetApi = { | ||||
|   create: async ( | ||||
|     server: any, | ||||
|     accessToken: string, | ||||
|     dto?: Omit<CreateAssetDto, 'assetData'>, | ||||
|   ): Promise<AssetResponseDto> => { | ||||
|     dto = dto || asset; | ||||
|     const { status, body } = await request(server) | ||||
|       .post(`/asset/upload`) | ||||
|       .field('deviceAssetId', dto.deviceAssetId) | ||||
|       .field('deviceId', dto.deviceId) | ||||
|       .field('fileCreatedAt', dto.fileCreatedAt.toISOString()) | ||||
|       .field('fileModifiedAt', dto.fileModifiedAt.toISOString()) | ||||
|       .attach('assetData', randomBytes(32), 'example.jpg') | ||||
|       .set('Authorization', `Bearer ${accessToken}`); | ||||
|  | ||||
|     expect([200, 201].includes(status)).toBe(true); | ||||
|  | ||||
|     return body as AssetResponseDto; | ||||
|   }, | ||||
|   get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => { | ||||
|     const { body, status } = await request(server).get(`/asset/${id}`).set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(200); | ||||
|     return body as AssetResponseDto; | ||||
|   }, | ||||
|   getAllAssets: async (server: any, accessToken: string) => { | ||||
|     const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(200); | ||||
|     return body as AssetResponseDto[]; | ||||
|   }, | ||||
|   upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => { | ||||
|     const { content, filename, isFavorite = false, isArchived = false } = dto; | ||||
|     const { body, status } = await request(server) | ||||
|       .post('/asset/upload') | ||||
|       .set('Authorization', `Bearer ${accessToken}`) | ||||
|       .field('deviceAssetId', deviceAssetId) | ||||
|       .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), filename || 'example.jpg'); | ||||
|  | ||||
|     expect(status).toBe(201); | ||||
|     return body as AssetFileUploadResponseDto; | ||||
|   }, | ||||
|   getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => { | ||||
|     const { body, status } = await request(server) | ||||
|       .get(`/asset/thumbnail/${assetId}`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(200); | ||||
|     return body; | ||||
|   }, | ||||
|   getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => { | ||||
|     const { body, status } = await request(server) | ||||
|       .get(`/asset/thumbnail/${assetId}?format=JPEG`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(200); | ||||
|     return body; | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; | ||||
| import { LoginResponseDto, UserResponseDto } from '@app/domain'; | ||||
| import { adminSignupStub, loginResponseStub, loginStub } from '@test'; | ||||
| import request from 'supertest'; | ||||
|  | ||||
| @@ -17,14 +17,6 @@ export const authApi = { | ||||
|     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; | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,15 +1,9 @@ | ||||
| import { assetApi } from './asset-api'; | ||||
| import { authApi } from './auth-api'; | ||||
| import { libraryApi } from './library-api'; | ||||
| import { sharedLinkApi } from './shared-link-api'; | ||||
| import { trashApi } from './trash-api'; | ||||
| import { userApi } from './user-api'; | ||||
|  | ||||
| export const api = { | ||||
|   authApi, | ||||
|   assetApi, | ||||
|   libraryApi, | ||||
|   sharedLinkApi, | ||||
|   trashApi, | ||||
|   userApi, | ||||
| }; | ||||
|   | ||||
| @@ -1,12 +1,4 @@ | ||||
| import { | ||||
|   CreateLibraryDto, | ||||
|   LibraryResponseDto, | ||||
|   LibraryStatsResponseDto, | ||||
|   ScanLibraryDto, | ||||
|   UpdateLibraryDto, | ||||
|   ValidateLibraryDto, | ||||
|   ValidateLibraryResponseDto, | ||||
| } from '@app/domain'; | ||||
| import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from '@app/domain'; | ||||
| import request from 'supertest'; | ||||
|  | ||||
| export const libraryApi = { | ||||
| @@ -38,34 +30,4 @@ export const libraryApi = { | ||||
|       .send(dto); | ||||
|     expect(status).toBe(204); | ||||
|   }, | ||||
|   removeOfflineFiles: async (server: any, accessToken: string, id: string) => { | ||||
|     const { status } = await request(server) | ||||
|       .post(`/library/${id}/removeOffline`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`) | ||||
|       .send(); | ||||
|     expect(status).toBe(204); | ||||
|   }, | ||||
|   getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => { | ||||
|     const { body, status } = await request(server) | ||||
|       .get(`/library/${id}/statistics`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(200); | ||||
|     return body; | ||||
|   }, | ||||
|   update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => { | ||||
|     const { body, status } = await request(server) | ||||
|       .put(`/library/${id}`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`) | ||||
|       .send(data); | ||||
|     expect(status).toBe(200); | ||||
|     return body as LibraryResponseDto; | ||||
|   }, | ||||
|   validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => { | ||||
|     const { body, status } = await request(server) | ||||
|       .post(`/library/${id}/validate`) | ||||
|       .set('Authorization', `Bearer ${accessToken}`) | ||||
|       .send(data); | ||||
|     expect(status).toBe(200); | ||||
|     return body as ValidateLibraryResponseDto; | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,13 +0,0 @@ | ||||
| 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; | ||||
|   }, | ||||
| }; | ||||
| @@ -1,13 +0,0 @@ | ||||
| import request from 'supertest'; | ||||
| import type { App } from 'supertest/types'; | ||||
|  | ||||
| export const trashApi = { | ||||
|   async empty(server: App, accessToken: string) { | ||||
|     const { status } = await request(server).post('/trash/empty').set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(204); | ||||
|   }, | ||||
|   async restore(server: App, accessToken: string) { | ||||
|     const { status } = await request(server).post('/trash/restore').set('Authorization', `Bearer ${accessToken}`); | ||||
|     expect(status).toBe(204); | ||||
|   }, | ||||
| }; | ||||
| @@ -1,37 +0,0 @@ | ||||
| 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; | ||||
|   }, | ||||
|   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; | ||||
|   }, | ||||
| }; | ||||
| @@ -23,7 +23,6 @@ | ||||
|     "test:cov": "jest --coverage", | ||||
|     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", | ||||
|     "e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand", | ||||
|     "e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand", | ||||
|     "typeorm": "typeorm", | ||||
|     "typeorm:migrations:create": "typeorm migration:create", | ||||
|     "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user