From 30b0b2474e9d1d73f11d35ccf6f34414e2153c1b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 9 Mar 2024 12:51:58 -0500 Subject: [PATCH] refactor: asset e2e (#7769) --- .github/workflows/test.yml | 17 - Makefile | 3 - e2e/src/api/specs/asset.e2e-spec.ts | 563 +++++++++++- e2e/src/api/specs/search.e2e-spec.ts | 351 ++++++-- e2e/src/fixtures.ts | 13 + e2e/src/utils.ts | 14 +- server/e2e/api/jest-e2e.json | 23 - server/e2e/api/setup.ts | 29 - server/e2e/api/specs/asset.e2e-spec.ts | 1151 ------------------------ server/e2e/api/utils.ts | 118 --- server/e2e/client/asset-api.ts | 67 -- server/e2e/client/auth-api.ts | 10 +- server/e2e/client/index.ts | 6 - server/e2e/client/library-api.ts | 40 +- server/e2e/client/shared-link-api.ts | 13 - server/e2e/client/trash-api.ts | 13 - server/e2e/client/user-api.ts | 37 - server/package.json | 1 - 18 files changed, 852 insertions(+), 1617 deletions(-) delete mode 100644 server/e2e/api/jest-e2e.json delete mode 100644 server/e2e/api/setup.ts delete mode 100644 server/e2e/api/specs/asset.e2e-spec.ts delete mode 100644 server/e2e/api/utils.ts delete mode 100644 server/e2e/client/shared-link-api.ts delete mode 100644 server/e2e/client/trash-api.ts delete mode 100644 server/e2e/client/user-api.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6b17774ed..9adcfe7373 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Makefile b/Makefile index b455e2656b..55875e732b 100644 --- a/Makefile +++ b/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 diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index f1bb355315..65ad094be6 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -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 => { + 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; +}; + 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 }), + ]), + ); + }); + }); }); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index de7d9ef4c5..19b1b68073 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -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', () => { diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 6a1a1b3968..56070e6e34 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -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 = { diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index d62497b8e4..af86a608db 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -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; diff --git a/server/e2e/api/jest-e2e.json b/server/e2e/api/jest-e2e.json deleted file mode 100644 index 9fd67774f3..0000000000 --- a/server/e2e/api/jest-e2e.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "modulePaths": [""], - "rootDir": "../..", - "globalSetup": "/e2e/api/setup.ts", - "testEnvironment": "node", - "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "/src/**/*.(t|j)s", - "!/src/**/*.spec.(t|s)s", - "!/src/infra/migrations/**" - ], - "coverageDirectory": "./coverage", - "moduleNameMapper": { - "^@test(|/.*)$": "/test/$1", - "^@app/immich(|/.*)$": "/src/immich/$1", - "^@app/infra(|/.*)$": "/src/infra/$1", - "^@app/domain(|/.*)$": "/src/domain/$1" - } -} diff --git a/server/e2e/api/setup.ts b/server/e2e/api/setup.ts deleted file mode 100644 index 88f2f598bd..0000000000 --- a/server/e2e/api/setup.ts +++ /dev/null @@ -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'; - } -}; diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts deleted file mode 100644 index 6badd4c674..0000000000 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ /dev/null @@ -1,1151 +0,0 @@ -import { - AssetResponseDto, - IAssetRepository, - IPersonRepository, - LibraryResponseDto, - LoginResponseDto, - TimeBucketSize, - WithoutProperty, - mapAsset, - usePagination, -} from '@app/domain'; -import { AssetController } from '@app/immich'; -import { AssetEntity, AssetStackEntity, AssetType, SharedLinkType } from '@app/infra/entities'; -import { AssetRepository } from '@app/infra/repositories'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { assetApi } from 'e2e/client/asset-api'; -import { randomBytes } from 'node:crypto'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp, today, yesterday } from '../utils'; - -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; -}; - -describe(`${AssetController.name} (e2e)`, () => { - let app: INestApplication; - let server: any; - let assetRepository: IAssetRepository; - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - let userWithQuota: LoginResponseDto; - let libraries: LibraryResponseDto[]; - let asset1: AssetResponseDto; - let asset2: AssetResponseDto; - let asset3: AssetResponseDto; - let asset4: AssetResponseDto; - let asset5: AssetResponseDto; - let asset6: AssetResponseDto; - - const createAsset = async ( - loginResponse: LoginResponseDto, - fileCreatedAt: Date, - other: Partial = {}, - ) => { - const asset = await assetRepository.create( - generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }), - ); - - return mapAsset(asset); - }; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - - await testApp.reset(); - - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), - api.userApi.create(server, admin.accessToken, userDto.userWithQuota), - ]); - - [user1, user2, userWithQuota] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), - api.authApi.login(server, userDto.userWithQuota), - ]); - - libraries = await api.libraryApi.getAll(server, admin.accessToken); - }); - - beforeEach(async () => { - await testApp.reset({ entities: [AssetEntity, AssetStackEntity] }); - - [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-02-10')), - createAsset(user1, new Date('1970-02-11'), { - isFavorite: true, - isExternal: true, - isReadOnly: true, - type: AssetType.VIDEO, - fileCreatedAt: yesterday.toJSDate(), - fileModifiedAt: yesterday.toJSDate(), - createdAt: yesterday.toJSDate(), - updatedAt: yesterday.toJSDate(), - localDateTime: yesterday.toJSDate(), - encodedVideoPath: '/path/to/encoded-video.mp4', - webpPath: '/path/to/thumb.webp', - resizePath: '/path/to/thumb.jpg', - }), - createAsset(user2, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01'), { - deletedAt: yesterday.toJSDate(), - }), - createAsset(user1, new Date('1970-02-11'), { - isArchived: true, - }), - ]); - - await assetRepository.upsertExif({ - assetId: asset3.id, - latitude: 90, - longitude: 90, - city: 'Immich', - state: 'Nebraska', - country: 'United States', - make: 'Cannon', - model: 'EOS Rebel T7', - lensModel: 'Fancy lens', - }); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - describe('GET /assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/assets'); - expect(body).toEqual(errorStub.unauthorized); - expect(status).toBe(401); - }); - - const badTests = [ - // - { - should: 'should reject page as a string', - query: { page: 'abc' }, - expected: ['page must not be less than 1', 'page must be an integer number'], - }, - { - should: 'should reject page as a decimal', - query: { page: 1.5 }, - expected: ['page must be an integer number'], - }, - { - should: 'should reject page as a negative number', - query: { page: -10 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject page as 0', - query: { page: 0 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject size as a string', - query: { 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', - query: { 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`, - query: { [value]: 'immich' }, - expected: [`${value} must be a boolean value`], - })), - ]; - - for (const { should, query, expected } of badTests) { - it(should, async () => { - const { status, body } = await request(server) - .get('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query(query); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(expected)); - }); - } - - const searchTests = [ - { - should: 'should only return my own assets', - deferred: () => ({ - query: {}, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should sort my assets in reverse', - deferred: () => ({ - query: { order: 'asc' }, - assets: [asset1, asset2, asset3], - }), - }, - { - should: 'should support custom page sizes', - deferred: () => ({ - query: { size: 1 }, - assets: [asset3], - }), - }, - { - should: 'should support pagination', - deferred: () => ({ - query: { size: 1, page: 2 }, - assets: [asset2], - }), - }, - { - should: 'should search by checksum (base64)', - deferred: () => ({ - query: { checksum: asset1.checksum }, - assets: [asset1], - }), - }, - { - should: 'should search by checksum (hex)', - deferred: () => ({ - query: { checksum: Buffer.from(asset1.checksum, 'base64').toString('hex') }, - assets: [asset1], - }), - }, - { - should: 'should search by id', - deferred: () => ({ - query: { id: asset1.id }, - assets: [asset1], - }), - }, - { - should: 'should search by isFavorite (true)', - deferred: () => ({ - query: { isFavorite: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isFavorite (false)', - deferred: () => ({ - query: { isFavorite: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by isArchived (true)', - deferred: () => ({ - query: { isArchived: true }, - assets: [asset6], - }), - }, - { - should: 'should search by isArchived (false)', - deferred: () => ({ - query: { isArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by isReadOnly (true)', - deferred: () => ({ - query: { isReadOnly: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isReadOnly (false)', - deferred: () => ({ - query: { isReadOnly: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (image)', - deferred: () => ({ - query: { type: 'IMAGE' }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (video)', - deferred: () => ({ - query: { type: 'VIDEO' }, - assets: [asset3], - }), - }, - { - should: 'should search by withArchived (true)', - deferred: () => ({ - query: { withArchived: true }, - assets: [asset3, asset6, asset2, asset1], - }), - }, - { - should: 'should search by withArchived (false)', - deferred: () => ({ - query: { withArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdBefore', - deferred: () => ({ - query: { createdBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by createdBefore (no results)', - deferred: () => ({ - query: { createdBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by createdAfter', - deferred: () => ({ - query: { createdAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdAfter (no results)', - deferred: () => ({ - query: { createdAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedBefore', - deferred: () => ({ - query: { updatedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by updatedBefore (no results)', - deferred: () => ({ - query: { updatedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedAfter', - deferred: () => ({ - query: { updatedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by updatedAfter (no results)', - deferred: () => ({ - query: { updatedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedBefore', - deferred: () => ({ - query: { trashedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedBefore (no results)', - deferred: () => ({ - query: { trashedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedAfter', - deferred: () => ({ - query: { trashedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedAfter (no results)', - deferred: () => ({ - query: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenBefore', - deferred: () => ({ - query: { takenBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by takenBefore (no results)', - deferred: () => ({ - query: { takenBefore: yesterday.minus({ years: 100 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenAfter', - deferred: () => ({ - query: { takenAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by takenAfter (no results)', - deferred: () => ({ - query: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by originalPath', - deferred: () => ({ - query: { originalPath: asset1.originalPath }, - assets: [asset1], - }), - }, - { - should: 'should search by originalFilename', - deferred: () => ({ - query: { originalFileName: asset1.originalFileName }, - assets: [asset1], - }), - }, - { - should: 'should search by encodedVideoPath', - deferred: () => ({ - query: { encodedVideoPath: '/path/to/encoded-video.mp4' }, - assets: [asset3], - }), - }, - { - should: 'should search by resizePath', - deferred: () => ({ - query: { resizePath: '/path/to/thumb.jpg' }, - assets: [asset3], - }), - }, - { - should: 'should search by webpPath', - deferred: () => ({ - query: { webpPath: '/path/to/thumb.webp' }, - assets: [asset3], - }), - }, - { - should: 'should search by city', - deferred: () => ({ - query: { city: 'Immich' }, - assets: [asset3], - }), - }, - { - should: 'should search by state', - deferred: () => ({ - query: { state: 'Nebraska' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { country: 'United States' }, - assets: [asset3], - }), - }, - { - should: 'should search by make', - deferred: () => ({ - query: { make: 'Cannon' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { model: 'EOS Rebel T7' }, - assets: [asset3], - }), - }, - { - should: 'should search by lensModel', - deferred: () => ({ - query: { lensModel: 'Fancy lens' }, - assets: [asset3], - }), - }, - ]; - - for (const { should, deferred } of searchTests) { - it(should, async () => { - const { assets, query } = deferred(); - const { status, body } = await request(server) - .get('/assets') - .query(query) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body.length).toBe(assets.length); - for (const [i, asset] of assets.entries()) { - expect(body[i]).toEqual(expect.objectContaining({ id: asset.id })); - } - }); - } - - it('should return stack data', async () => { - const parentId = asset1.id; - const childIds = [asset2.id, asset3.id]; - await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parentId, ids: childIds }); - - const body = await assetApi.getAllAssets(server, user1.accessToken); - // Response includes parent with stack children count - const parentDto = body.find((a) => a.id == parentId); - expect(parentDto?.stackCount).toEqual(3); - - // Response includes children at the root level - expect.arrayContaining([expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })]); - }); - }); - - 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 `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 have correct original file name and extension (simple)', 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, originalFileName: 'example.jpg' }); - }); - - it('should have correct original file name and extension (complex)', 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.complex.ext.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, originalFileName: 'example.complex.ext.jpg' }); - }); - - 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); - }); - - it("should not upload to another user's library", async () => { - const content = randomBytes(32); - const [library] = await api.libraryApi.getAll(server, admin.accessToken); - 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('libraryId', library.id) - .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(400); - expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access')); - }); - - it('should update the used quota', async () => { - const content = randomBytes(32); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.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', content, 'example.jpg'); - - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const { body: user } = await request(server) - .get('/user/me') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`); - - expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 32 })); - }); - - it('should not upload an asset if it would exceed the quota', async () => { - const content = randomBytes(420); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.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', content, 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Quota has been exceeded!')); - }); - }); - - 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: '2023-11-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - { count: 2, timeBucket: '1970-02-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id, asset2.id], - }); - - const { status, body } = await request(server) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.MONTH }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - 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 handle 5 digit years', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.MONTH, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // 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: asset2.id })])); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .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(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(server) - .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(errorStub.badRequest()); - }); - }); - - describe('GET /asset/map-marker', () => { - beforeEach(async () => { - await Promise.all([ - assetRepository.save({ id: asset1.id, isArchived: true }), - assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }), - assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }), - ]); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/map-marker'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get map markers for all non-archived assets', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .query({ isArchived: false }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - - it('should get all map markers', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isArchived: false }); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset4.id, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should add stack children', async () => { - const [parent, child] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01')), - ]); - - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parent.id, ids: [child.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, parent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - - it('should remove all stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - const newParent = await createAsset(user1, new Date('1970-01-01')); - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: newParent.id, ids: [asset1.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset/stack/parent', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset4.id, newParentId: asset1.id }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); - }); - - it('should make all childrens of old parent, a child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - }); - - const getAssetIdsWithoutFaces = async () => { - const assetPagination = usePagination(10, (pagination) => - assetRepository.getWithout(pagination, WithoutProperty.FACES), - ); - let assets: AssetEntity[] = []; - for await (const assetsPage of assetPagination) { - assets = [...assets, ...assetsPage]; - } - return assets.map((a) => a.id); - }; - - describe(AssetRepository.name, () => { - describe('getWithout', () => { - describe('WithoutProperty.FACES', () => { - beforeEach(async () => { - await assetRepository.save({ id: asset1.id, resizePath: '/path/to/resize' }); - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - describe('with recognized faces', () => { - beforeEach(async () => { - const personRepository = app.get(IPersonRepository); - const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); - await personRepository.createFaces([ - { - assetId: asset1.id, - personId: person.id, - embedding: Array.from({ length: 512 }, Math.random), - }, - ]); - }); - - it('should not return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - - describe('without recognized faces', () => { - it('should return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - }); - }); - }); -}); diff --git a/server/e2e/api/utils.ts b/server/e2e/api/utils.ts deleted file mode 100644 index c03c4ada55..0000000000 --- a/server/e2e/api/utils.ts +++ /dev/null @@ -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[]; -} -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 => { - 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 = {}, -): 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, - }; -} diff --git a/server/e2e/client/asset-api.ts b/server/e2e/client/asset-api.ts index 8d2a1b79bc..63d4395866 100644 --- a/server/e2e/client/asset-api.ts +++ b/server/e2e/client/asset-api.ts @@ -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 & { 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, - ): Promise => { - 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 => { - 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; - }, }; diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts index f0206d3376..e89e6d0576 100644 --- a/server/e2e/client/auth-api.ts +++ b/server/e2e/client/auth-api.ts @@ -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; }, }; diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts index b0464a34d8..b4aa2a141b 100644 --- a/server/e2e/client/index.ts +++ b/server/e2e/client/index.ts @@ -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, }; diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts index e0b1331267..070683eb01 100644 --- a/server/e2e/client/library-api.ts +++ b/server/e2e/client/library-api.ts @@ -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 => { - 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; - }, }; diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts deleted file mode 100644 index c34093b0ac..0000000000 --- a/server/e2e/client/shared-link-api.ts +++ /dev/null @@ -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; - }, -}; diff --git a/server/e2e/client/trash-api.ts b/server/e2e/client/trash-api.ts deleted file mode 100644 index a381253f50..0000000000 --- a/server/e2e/client/trash-api.ts +++ /dev/null @@ -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); - }, -}; diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts deleted file mode 100644 index c538db3a8f..0000000000 --- a/server/e2e/client/user-api.ts +++ /dev/null @@ -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; - }, -}; diff --git a/server/package.json b/server/package.json index 98ee13c1b1..1e3d31c7d8 100644 --- a/server/package.json +++ b/server/package.json @@ -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",