diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index fb02aff2f..8228ff289 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -4,7 +4,6 @@ name: immich-e2e x-server-build: &server-common image: immich-server:latest - container_name: immich-e2e-server build: context: ../ dockerfile: server/Dockerfile @@ -23,14 +22,16 @@ x-server-build: &server-common services: immich-server: + container_name: immich-e2e-server command: [ "./start.sh", "immich" ] <<: *server-common ports: - 2283:3001 - # immich-microservices: - # command: [ "./start.sh", "microservices" ] - # <<: *server-common + immich-microservices: + container_name: immich-e2e-microservices + command: [ "./start.sh", "microservices" ] + <<: *server-common redis: image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 44155ac83..954d1cc3f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -12,11 +12,14 @@ "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.41.2", + "@types/luxon": "^3.4.2", "@types/node": "^20.11.17", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "luxon": "^3.4.4", "pg": "^8.11.3", + "socket.io-client": "^4.7.4", "supertest": "^6.3.4", "typescript": "^5.3.3", "vitest": "^1.3.0" @@ -781,6 +784,12 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==", + "dev": true + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -799,6 +808,12 @@ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "dev": true + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1263,6 +1278,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -1704,6 +1741,15 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", @@ -2346,6 +2392,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/socket.io-client": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz", + "integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2743,6 +2817,36 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/e2e/package.json b/e2e/package.json index ebd5b9aea..7bbdfd1d9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -16,11 +16,14 @@ "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.41.2", + "@types/luxon": "^3.4.2", "@types/node": "^20.11.17", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "luxon": "^3.4.4", "pg": "^8.11.3", + "socket.io-client": "^4.7.4", "supertest": "^6.3.4", "typescript": "^5.3.3", "vitest": "^1.3.0" diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 738411338..39c075dba 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -1,7 +1,7 @@ import { ActivityCreateDto, AlbumResponseDto, - AssetResponseDto, + AssetFileUploadResponseDto, LoginResponseDto, ReactionType, createActivity as create, @@ -16,13 +16,13 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/activity', () => { let admin: LoginResponseDto; let nonOwner: LoginResponseDto; - let asset: AssetResponseDto; + let asset: AssetFileUploadResponseDto; let album: AlbumResponseDto; const createActivity = (dto: ActivityCreateDto, accessToken?: string) => create( { activityCreateDto: dto }, - { headers: asBearerAuth(accessToken || admin.accessToken) } + { headers: asBearerAuth(accessToken || admin.accessToken) }, ); beforeAll(async () => { @@ -40,7 +40,7 @@ describe('/activity', () => { sharedWithUserIds: [nonOwner.userId], }, }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ); }); @@ -61,7 +61,7 @@ describe('/activity', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); expect(body).toEqual( - errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])) + errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])), ); }); @@ -72,7 +72,7 @@ describe('/activity', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); expect(body).toEqual( - errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])) + errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])), ); }); @@ -83,7 +83,7 @@ describe('/activity', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); expect(body).toEqual( - errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])) + errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])), ); }); @@ -104,7 +104,7 @@ describe('/activity', () => { assetIds: [asset.id], }, }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ); const [reaction] = await Promise.all([ @@ -216,7 +216,7 @@ describe('/activity', () => { .send({ albumId: uuidDto.invalid }); expect(status).toEqual(400); expect(body).toEqual( - errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])) + errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])), ); }); @@ -230,7 +230,7 @@ describe('/activity', () => { errorDto.badRequest([ 'comment must be a string', 'comment should not be empty', - ]) + ]), ); }); @@ -357,7 +357,7 @@ describe('/activity', () => { describe('DELETE /activity/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).delete( - `/activity/${uuidDto.notFound}` + `/activity/${uuidDto.notFound}`, ); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -421,7 +421,7 @@ describe('/activity', () => { expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest('Not found or no activity.delete access') + errorDto.badRequest('Not found or no activity.delete access'), ); }); @@ -432,7 +432,7 @@ describe('/activity', () => { type: ReactionType.Comment, comment: 'This is a test comment', }, - nonOwner.accessToken + nonOwner.accessToken, ); const { status } = await request(app) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index c131edc49..3385e50f4 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -1,6 +1,6 @@ import { AlbumResponseDto, - AssetResponseDto, + AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, deleteUser, @@ -21,8 +21,8 @@ const user2NotShared = 'user2NotShared'; describe('/album', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user1Asset1: AssetResponseDto; - let user1Asset2: AssetResponseDto; + let user1Asset1: AssetFileUploadResponseDto; + let user1Asset2: AssetFileUploadResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; @@ -95,7 +95,7 @@ describe('/album', () => { await deleteUser( { id: user3.userId }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ); }); @@ -112,7 +112,7 @@ describe('/album', () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); expect(body).toEqual( - errorDto.badRequest(['shared must be a boolean value']) + errorDto.badRequest(['shared must be a boolean value']), ); }); @@ -148,7 +148,7 @@ describe('/album', () => { albumName: user2SharedUser, shared: true, }), - ]) + ]), ); }); @@ -175,7 +175,7 @@ describe('/album', () => { albumName: user1NotShared, shared: false, }), - ]) + ]), ); }); @@ -202,7 +202,7 @@ describe('/album', () => { albumName: user2SharedUser, shared: true, }), - ]) + ]), ); }); @@ -219,7 +219,7 @@ describe('/album', () => { albumName: user1NotShared, shared: false, }), - ]) + ]), ); }); @@ -251,7 +251,7 @@ describe('/album', () => { describe('GET /album/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).get( - `/album/${user1Albums[0].id}` + `/album/${user1Albums[0].id}`, ); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -361,7 +361,7 @@ describe('/album', () => { describe('PUT /album/:id/assets', () => { it('should require authentication', async () => { const { status, body } = await request(app).put( - `/album/${user1Albums[0].id}/assets` + `/album/${user1Albums[0].id}/assets`, ); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -519,7 +519,7 @@ describe('/album', () => { expect(body).toEqual( expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })], - }) + }), ); }); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts new file mode 100644 index 000000000..db1821260 --- /dev/null +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -0,0 +1,481 @@ +import { + AssetFileUploadResponseDto, + AssetResponseDto, + LoginResponseDto, + SharedLinkType, +} from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { Socket } from 'socket.io-client'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, dbUtils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const today = DateTime.fromObject({ + year: 2023, + month: 11, + day: 3, +}) as DateTime; +const yesterday = today.minus({ days: 1 }); + +describe('/asset', () => { + let admin: LoginResponseDto; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + let userStats: LoginResponseDto; + let asset1: AssetFileUploadResponseDto; + let asset2: AssetFileUploadResponseDto; + let asset3: AssetFileUploadResponseDto; + let asset4: AssetFileUploadResponseDto; // user2 asset + let asset5: AssetFileUploadResponseDto; + let asset6: AssetFileUploadResponseDto; + let ws: Socket; + + beforeAll(async () => { + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup({ onboarding: false }); + [user1, user2, userStats] = await Promise.all([ + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), + ]); + + [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset( + user1.accessToken, + { + isFavorite: true, + isExternal: true, + isReadOnly: true, + fileCreatedAt: yesterday.toISO(), + fileModifiedAt: yesterday.toISO(), + }, + { filename: 'example.mp4' }, + ), + apiUtils.createAsset(user2.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + + // stats + apiUtils.createAsset(userStats.accessToken), + apiUtils.createAsset(userStats.accessToken, { isFavorite: true }), + apiUtils.createAsset(userStats.accessToken, { isArchived: true }), + apiUtils.createAsset( + userStats.accessToken, + { + isArchived: true, + isFavorite: true, + }, + { filename: 'example.mp4' }, + ), + ]); + + const person1 = await apiUtils.createPerson(user1.accessToken, { + name: 'Test Person', + }); + await dbUtils.createFace({ assetId: asset1.id, personId: person1.id }); + }); + + describe('GET /asset/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/${uuidDto.notFound}`, + ); + expect(body).toEqual(errorDto.unauthorized); + expect(status).toBe(401); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .get(`/asset/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .get(`/asset/${asset4.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get the asset info', async () => { + const { status, body } = await request(app) + .get(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ id: asset1.id }); + }); + + it('should work with a shared link', async () => { + const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset1.id], + }); + + const { status, body } = await request(app).get( + `/asset/${asset1.id}?key=${sharedLink.key}`, + ); + expect(status).toBe(200); + expect(body).toMatchObject({ id: asset1.id }); + }); + + it('should not send people data for shared links for un-authenticated users', async () => { + const { status, body } = await request(app) + .get(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toEqual(200); + expect(body).toMatchObject({ + id: asset1.id, + isFavorite: false, + people: [ + { + birthDate: null, + id: expect.any(String), + isHidden: false, + name: 'Test Person', + thumbnailPath: '/my/awesome/thumbnail.jpg', + }, + ], + }); + + const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset1.id], + }); + + const data = await request(app).get( + `/asset/${asset1.id}?key=${sharedLink.key}`, + ); + expect(data.status).toBe(200); + expect(data.body).toMatchObject({ people: [] }); + }); + }); + + describe('GET /asset/statistics', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/statistics'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return stats of all assets', async () => { + const { status, body } = await request(app) + .get('/asset/statistics') + .set('Authorization', `Bearer ${userStats.accessToken}`); + + expect(body).toEqual({ images: 3, videos: 1, total: 4 }); + expect(status).toBe(200); + }); + + it('should return stats of all favored assets', async () => { + const { status, body } = await request(app) + .get('/asset/statistics') + .set('Authorization', `Bearer ${userStats.accessToken}`) + .query({ isFavorite: true }); + + expect(status).toBe(200); + expect(body).toEqual({ images: 1, videos: 1, total: 2 }); + }); + + it('should return stats of all archived assets', async () => { + const { status, body } = await request(app) + .get('/asset/statistics') + .set('Authorization', `Bearer ${userStats.accessToken}`) + .query({ isArchived: true }); + + expect(status).toBe(200); + expect(body).toEqual({ images: 1, videos: 1, total: 2 }); + }); + + 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}`) + .query({ isFavorite: true, isArchived: true }); + + expect(status).toBe(200); + expect(body).toEqual({ images: 0, videos: 1, total: 1 }); + }); + + 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}`) + .query({ isFavorite: false, isArchived: false }); + + expect(status).toBe(200); + expect(body).toEqual({ images: 1, videos: 0, total: 1 }); + }); + }); + + describe('GET /asset/random', () => { + beforeAll(async () => { + await Promise.all([ + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), + ]); + }); + + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/random'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it.each(Array(10))('should return 1 random assets', async () => { + const { status, body } = await request(app) + .get('/asset/random') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + + const assets: AssetResponseDto[] = body; + expect(assets.length).toBe(1); + expect(assets[0].ownerId).toBe(user1.userId); + // + // assets owned by user2 + expect(assets[0].id).not.toBe(asset4.id); + // assets owned by user1 + expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); + }); + + it.each(Array(10))('should return 2 random assets', async () => { + const { status, body } = await request(app) + .get('/asset/random?count=2') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + + const assets: AssetResponseDto[] = body; + expect(assets.length).toBe(2); + + for (const asset of assets) { + expect(asset.ownerId).toBe(user1.userId); + // assets owned by user1 + expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id); + // assets owned by user2 + expect(asset.id).not.toBe(asset4.id); + } + }); + + it.each(Array(10))( + 'should return 1 asset if there are 10 assets in the database but user 2 only has 1', + async () => { + const { status, body } = await request(app) + .get('/[]asset/random') + .set('Authorization', `Bearer ${user2.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: asset4.id })]); + }, + ); + + it('should return error', async () => { + const { status } = await request(app) + .get('/asset/random?count=ABC') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(400); + }); + }); + + describe('PUT /asset/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put( + `/asset/:${uuidDto.notFound}`, + ); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .put(`/asset/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .put(`/asset/${asset4.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should favorite an asset', async () => { + const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + expect(before.isFavorite).toBe(false); + + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ isFavorite: true }); + expect(body).toMatchObject({ id: asset1.id, isFavorite: true }); + expect(status).toEqual(200); + }); + + it('should archive an asset', async () => { + const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + expect(before.isArchived).toBe(false); + + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ isArchived: true }); + expect(body).toMatchObject({ id: asset1.id, isArchived: true }); + expect(status).toEqual(200); + }); + + it('should update date time original', async () => { + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + expect(body).toMatchObject({ + id: asset1.id, + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-20T01:11:00.000Z', + }), + }); + expect(status).toEqual(200); + }); + + it('should reject invalid gps coordinates', async () => { + for (const test of [ + { latitude: 12 }, + { longitude: 12 }, + { latitude: 12, longitude: 'abc' }, + { latitude: 'abc', longitude: 12 }, + { latitude: null, longitude: 12 }, + { latitude: 12, longitude: null }, + { latitude: 91, longitude: 12 }, + { latitude: -91, longitude: 12 }, + { latitude: 12, longitude: -181 }, + { latitude: 12, longitude: 181 }, + ]) { + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .send(test) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + } + }); + + it('should update gps data', async () => { + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ latitude: 12, longitude: 12 }); + + expect(body).toMatchObject({ + id: asset1.id, + exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), + }); + expect(status).toEqual(200); + }); + + it('should set the description', async () => { + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ description: 'Test asset description' }); + expect(body).toMatchObject({ + id: asset1.id, + exifInfo: expect.objectContaining({ + description: 'Test asset description', + }), + }); + expect(status).toEqual(200); + }); + + it('should return tagged people', async () => { + const { status, body } = await request(app) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ isFavorite: true }); + expect(status).toEqual(200); + expect(body).toMatchObject({ + id: asset1.id, + isFavorite: true, + people: [ + { + birthDate: null, + id: expect.any(String), + isHidden: false, + name: 'Test Person', + thumbnailPath: '/my/awesome/thumbnail.jpg', + }, + ], + }); + }); + }); + + describe('DELETE /asset', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .delete(`/asset`) + .send({ ids: [uuidDto.notFound] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/asset`) + .send({ ids: [uuidDto.invalid] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(['each value in ids must be a UUID']), + ); + }); + + it('should throw an error when the id is not found', async () => { + const { status, body } = await request(app) + .delete(`/asset`) + .send({ ids: [uuidDto.notFound] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest('Not found or no asset.delete access'), + ); + }); + + it('should move an asset to the trash', async () => { + const { id: assetId } = await apiUtils.createAsset(admin.accessToken); + + const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(false); + + const { status } = await request(app) + .delete('/asset') + .send({ ids: [assetId] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); + }); +}); diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts index f1d7bd112..22d66baf0 100644 --- a/e2e/src/api/specs/download.e2e-spec.ts +++ b/e2e/src/api/specs/download.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetResponseDto, LoginResponseDto } from '@immich/sdk'; +import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; import { errorDto } from 'src/responses'; import { apiUtils, app, dbUtils } from 'src/utils'; import request from 'supertest'; @@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/download', () => { let admin: LoginResponseDto; - let asset1: AssetResponseDto; + let asset1: AssetFileUploadResponseDto; beforeAll(async () => { apiUtils.setup(); @@ -35,7 +35,7 @@ describe('/download', () => { expect(body).toEqual( expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })], - }) + }), ); }); }); @@ -43,7 +43,7 @@ describe('/download', () => { describe('POST /download/asset/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).post( - `/download/asset/${asset1.id}` + `/download/asset/${asset1.id}`, ); expect(status).toBe(401); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index e791c447a..0bb760fbc 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -1,11 +1,9 @@ import { AlbumResponseDto, - AssetResponseDto, + AssetFileUploadResponseDto, LoginResponseDto, - SharedLinkCreateDto, SharedLinkResponseDto, SharedLinkType, - createSharedLink as create, createAlbum, deleteUser, } from '@immich/sdk'; @@ -17,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/shared-link', () => { let admin: LoginResponseDto; - let asset1: AssetResponseDto; - let asset2: AssetResponseDto; + let asset1: AssetFileUploadResponseDto; + let asset2: AssetFileUploadResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; let album: AlbumResponseDto; @@ -50,11 +48,11 @@ describe('/shared-link', () => { [album, deletedAlbum, metadataAlbum] = await Promise.all([ createAlbum( { createAlbumDto: { albumName: 'album' } }, - { headers: asBearerAuth(user1.accessToken) } + { headers: asBearerAuth(user1.accessToken) }, ), createAlbum( { createAlbumDto: { albumName: 'deleted album' } }, - { headers: asBearerAuth(user2.accessToken) } + { headers: asBearerAuth(user2.accessToken) }, ), createAlbum( { @@ -63,7 +61,7 @@ describe('/shared-link', () => { assetIds: [asset1.id], }, }, - { headers: asBearerAuth(user1.accessToken) } + { headers: asBearerAuth(user1.accessToken) }, ), ]); @@ -106,7 +104,7 @@ describe('/shared-link', () => { await deleteUser( { id: user2.userId }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ); }); @@ -132,7 +130,7 @@ describe('/shared-link', () => { expect.objectContaining({ id: linkWithPassword.id }), expect.objectContaining({ id: linkWithMetadata.id }), expect.objectContaining({ id: linkWithoutMetadata.id }), - ]) + ]), ); }); @@ -166,7 +164,7 @@ describe('/shared-link', () => { album, userId: user1.userId, type: SharedLinkType.Album, - }) + }), ); }); @@ -208,7 +206,7 @@ describe('/shared-link', () => { album, userId: user1.userId, type: SharedLinkType.Album, - }) + }), ); }); @@ -225,7 +223,7 @@ describe('/shared-link', () => { localDateTime: expect.any(String), fileCreatedAt: expect.any(String), exifInfo: expect.any(Object), - }) + }), ); expect(body.album).toBeDefined(); }); @@ -250,7 +248,7 @@ describe('/shared-link', () => { describe('GET /shared-link/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).get( - `/shared-link/${linkWithAlbum.id}` + `/shared-link/${linkWithAlbum.id}`, ); expect(status).toBe(401); @@ -268,7 +266,7 @@ describe('/shared-link', () => { album, userId: user1.userId, type: SharedLinkType.Album, - }) + }), ); }); @@ -279,7 +277,7 @@ describe('/shared-link', () => { expect(status).toBe(400); expect(body).toEqual( - expect.objectContaining({ message: 'Shared link not found' }) + expect.objectContaining({ message: 'Shared link not found' }), ); }); }); @@ -311,7 +309,7 @@ describe('/shared-link', () => { expect(status).toBe(400); expect(body).toEqual( - expect.objectContaining({ message: 'Invalid albumId' }) + expect.objectContaining({ message: 'Invalid albumId' }), ); }); @@ -323,7 +321,7 @@ describe('/shared-link', () => { expect(status).toBe(400); expect(body).toEqual( - expect.objectContaining({ message: 'Invalid assetIds' }) + expect.objectContaining({ message: 'Invalid assetIds' }), ); }); @@ -338,7 +336,7 @@ describe('/shared-link', () => { expect.objectContaining({ type: SharedLinkType.Album, userId: user1.userId, - }) + }), ); }); }); @@ -375,7 +373,7 @@ describe('/shared-link', () => { type: SharedLinkType.Album, userId: user1.userId, description: 'foo', - }) + }), ); }); }); @@ -427,7 +425,7 @@ describe('/shared-link', () => { describe('DELETE /shared-link/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).delete( - `/shared-link/${linkWithAlbum.id}` + `/shared-link/${linkWithAlbum.id}`, ); expect(status).toBe(401); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts new file mode 100644 index 000000000..2de838f98 --- /dev/null +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -0,0 +1,107 @@ +import { LoginResponseDto, getAllAssets } from '@immich/sdk'; +import { Socket } from 'socket.io-client'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/trash', () => { + let admin: LoginResponseDto; + let ws: Socket; + + beforeAll(async () => { + apiUtils.setup(); + await dbUtils.reset(); + admin = await apiUtils.adminSetup({ onboarding: false }); + ws = await wsUtils.connect(admin.accessToken); + }); + + afterAll(() => { + wsUtils.disconnect(ws); + }); + + describe('POST /trash/empty', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/trash/empty'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should empty the trash', async () => { + const { id: assetId } = await apiUtils.createAsset(admin.accessToken); + await apiUtils.deleteAssets(admin.accessToken, [assetId]); + + const before = await getAllAssets( + {}, + { headers: asBearerAuth(admin.accessToken) }, + ); + + expect(before.length).toBeGreaterThanOrEqual(1); + + const { status } = await request(app) + .post('/trash/empty') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + await wsUtils.once(ws, 'on_asset_delete'); + + const after = await getAllAssets( + {}, + { headers: asBearerAuth(admin.accessToken) }, + ); + expect(after.length).toBe(0); + }); + }); + + describe('POST /trash/restore', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/trash/restore'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should restore all trashed assets', async () => { + const { id: assetId } = await apiUtils.createAsset(admin.accessToken); + await apiUtils.deleteAssets(admin.accessToken, [assetId]); + + const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(false); + }); + }); + + describe('POST /trash/restore/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/trash/restore/assets'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should restore a trashed asset by id', async () => { + const { id: assetId } = await apiUtils.createAsset(admin.accessToken); + await apiUtils.deleteAssets(admin.accessToken, [assetId]); + + const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(204); + + const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(false); + }); + }); +}); diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts index e9e89befd..038a2c2ca 100644 --- a/e2e/src/cli/specs/server-info.e2e-spec.ts +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -1,12 +1,9 @@ import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe(`immich server-info`, () => { - beforeAll(() => { + beforeAll(async () => { apiUtils.setup(); - }); - - beforeEach(async () => { await dbUtils.reset(); await cliUtils.login(); }); diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts index 6dd664e1e..908118d77 100644 --- a/e2e/src/cli/specs/upload.e2e-spec.ts +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -1,4 +1,5 @@ import { getAllAlbums, getAllAssets } from '@immich/sdk'; +import { mkdir, readdir, rm, symlink } from 'fs/promises'; import { apiUtils, asKeyAuth, @@ -8,18 +9,18 @@ import { testAssetDir, } from 'src/utils'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { mkdir, readdir, rm, symlink } from 'fs/promises'; describe(`immich upload`, () => { let key: string; - beforeAll(() => { + beforeAll(async () => { apiUtils.setup(); + await dbUtils.reset(); + key = await cliUtils.login(); }); beforeEach(async () => { - await dbUtils.reset(); - key = await cliUtils.login(); + await dbUtils.reset(['assets', 'albums']); }); describe('immich upload --recursive', () => { @@ -33,7 +34,7 @@ describe(`immich upload`, () => { expect(stdout.split('\n')).toEqual( expect.arrayContaining([ expect.stringContaining('Successfully uploaded 9 assets'), - ]) + ]), ); expect(exitCode).toBe(0); @@ -55,7 +56,7 @@ describe(`immich upload`, () => { expect.stringContaining('Successfully uploaded 9 assets'), expect.stringContaining('Successfully created 1 new album'), expect.stringContaining('Successfully updated 9 assets'), - ]) + ]), ); expect(stderr).toBe(''); expect(exitCode).toBe(0); @@ -77,7 +78,7 @@ describe(`immich upload`, () => { expect(response1.stdout.split('\n')).toEqual( expect.arrayContaining([ expect.stringContaining('Successfully uploaded 9 assets'), - ]) + ]), ); expect(response1.stderr).toBe(''); expect(response1.exitCode).toBe(0); @@ -97,10 +98,10 @@ describe(`immich upload`, () => { expect(response2.stdout.split('\n')).toEqual( expect.arrayContaining([ expect.stringContaining( - 'All assets were already uploaded, nothing to do.' + 'All assets were already uploaded, nothing to do.', ), expect.stringContaining('Successfully updated 9 assets'), - ]) + ]), ); expect(response2.stderr).toBe(''); expect(response2.exitCode).toBe(0); @@ -127,7 +128,7 @@ describe(`immich upload`, () => { expect.stringContaining('Successfully uploaded 9 assets'), expect.stringContaining('Successfully created 1 new album'), expect.stringContaining('Successfully updated 9 assets'), - ]) + ]), ); expect(stderr).toBe(''); expect(exitCode).toBe(0); @@ -148,7 +149,7 @@ describe(`immich upload`, () => { for (const file of filesToLink) { await symlink( `${testAssetDir}/albums/nature/${file}`, - `/tmp/albums/nature/${file}` + `/tmp/albums/nature/${file}`, ); } @@ -166,7 +167,7 @@ describe(`immich upload`, () => { expect.arrayContaining([ expect.stringContaining('Successfully uploaded 9 assets'), expect.stringContaining('Deleting assets that have been uploaded'), - ]) + ]), ); expect(stderr).toBe(''); expect(exitCode).toBe(0); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index fbc0b43b3..428c88b45 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,5 @@ import { - AssetResponseDto, + AssetFileUploadResponseDto, CreateAlbumDto, CreateAssetDto, CreateUserDto, @@ -11,6 +11,8 @@ import { createSharedLink, createUser, defaults, + deleteAssets, + getAssetInfo, login, setAdminOnboarding, signUpAdmin, @@ -23,6 +25,7 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import pg from 'pg'; +import { io, type Socket } from 'socket.io-client'; import { loginDto, signupDto } from 'src/fixtures'; import request from 'supertest'; @@ -39,15 +42,19 @@ const directoryExists = (directory: string) => export const testAssetDir = path.resolve(`./../server/test/assets/`); const serverContainerName = 'immich-e2e-server'; -const uploadMediaDir = '/usr/src/app/upload/upload'; +const mediaDir = '/usr/src/app/upload'; +const dirs = [ + `"${mediaDir}/thumbs"`, + `"${mediaDir}/upload"`, + `"${mediaDir}/library"`, +].join(' '); if (!(await directoryExists(`${testAssetDir}/albums`))) { throw new Error( - `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing` + `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`, ); } -const setBaseUrl = () => (defaults.baseUrl = app); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}`, }); @@ -59,7 +66,7 @@ let client: pg.Client | null = null; export const fileUtils = { reset: async () => { await execPromise( - `docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"` + `docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`, ); }, }; @@ -81,7 +88,7 @@ export const dbUtils = { await client.query( 'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', - [assetId, personId, embedding] + [assetId, personId, embedding], ); }, setPersonThumbnail: async (personId: string) => { @@ -91,14 +98,14 @@ export const dbUtils = { await client.query( `UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, - [personId] + [personId], ); }, reset: async (tables?: string[]) => { try { if (!client) { client = new pg.Client( - 'postgres://postgres:postgres@127.0.0.1:5433/immich' + 'postgres://postgres:postgres@127.0.0.1:5433/immich', ); await client.connect(); } @@ -170,10 +177,42 @@ export interface AdminSetupOptions { onboarding?: boolean; } +export const wsUtils = { + connect: async (accessToken: string) => { + const websocket = io('http://127.0.0.1:2283', { + path: '/api/socket.io', + transports: ['websocket'], + extraHeaders: { Authorization: `Bearer ${accessToken}` }, + autoConnect: false, + forceNew: true, + }); + + return new Promise((resolve) => { + websocket.on('connect', () => resolve(websocket)); + websocket.connect(); + }); + }, + disconnect: (ws: Socket) => { + if (ws?.connected) { + ws.disconnect(); + } + }, + once: (ws: Socket, event: string): Promise => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout')), 4000); + ws.once(event, (data: T) => { + clearTimeout(timeout); + resolve(data); + }); + }); + }, +}; + export const apiUtils = { setup: () => { - setBaseUrl(); + defaults.baseUrl = app; }, + adminSetup: async (options?: AdminSetupOptions) => { options = options || { onboarding: true }; @@ -187,7 +226,7 @@ export const apiUtils = { userSetup: async (accessToken: string, dto: CreateUserDto) => { await createUser( { createUserDto: dto }, - { headers: asBearerAuth(accessToken) } + { headers: asBearerAuth(accessToken) }, ); return login({ loginCredentialDto: { email: dto.email, password: dto.password }, @@ -196,48 +235,74 @@ export const apiUtils = { createApiKey: (accessToken: string) => { return createApiKey( { apiKeyCreateDto: { name: 'e2e' } }, - { headers: asBearerAuth(accessToken) } + { headers: asBearerAuth(accessToken) }, ); }, createAlbum: (accessToken: string, dto: CreateAlbumDto) => createAlbum( { createAlbumDto: dto }, - { headers: asBearerAuth(accessToken) } + { headers: asBearerAuth(accessToken) }, ), createAsset: async ( accessToken: string, - dto?: Omit + dto?: Partial>, + data?: { + bytes?: Buffer; + filename?: string; + }, ) => { - dto = dto || { + const _dto = { deviceAssetId: 'test-1', deviceId: 'test', fileCreatedAt: new Date().toISOString(), fileModifiedAt: new Date().toISOString(), + ...(dto || {}), }; - const { body } = await request(app) + + const _assetData = { + bytes: randomBytes(32), + filename: 'example.jpg', + ...(data || {}), + }; + + const builder = request(app) .post(`/asset/upload`) - .field('deviceAssetId', dto.deviceAssetId) - .field('deviceId', dto.deviceId) - .field('fileCreatedAt', dto.fileCreatedAt) - .field('fileModifiedAt', dto.fileModifiedAt) - .attach('assetData', randomBytes(32), 'example.jpg') + .attach('assetData', _assetData.bytes, _assetData.filename) .set('Authorization', `Bearer ${accessToken}`); - return body as AssetResponseDto; + for (const [key, value] of Object.entries(_dto)) { + builder.field(key, String(value)); + } + + const { body } = await builder; + + return body as AssetFileUploadResponseDto; }, - createPerson: async (accessToken: string, dto: PersonUpdateDto) => { + getAssetInfo: (accessToken: string, id: string) => + getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), + deleteAssets: (accessToken: string, ids: string[]) => + deleteAssets( + { assetBulkDeleteDto: { ids } }, + { headers: asBearerAuth(accessToken) }, + ), + createPerson: async (accessToken: string, dto?: PersonUpdateDto) => { // TODO fix createPerson to accept a body - const { id } = await createPerson({ headers: asBearerAuth(accessToken) }); - await dbUtils.setPersonThumbnail(id); + let person = await createPerson({ headers: asBearerAuth(accessToken) }); + await dbUtils.setPersonThumbnail(person.id); + + if (!dto) { + return person; + } + return updatePerson( - { id, personUpdateDto: dto }, - { headers: asBearerAuth(accessToken) } + { id: person.id, personUpdateDto: dto }, + { headers: asBearerAuth(accessToken) }, ); }, createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => createSharedLink( { sharedLinkCreateDto: dto }, - { headers: asBearerAuth(accessToken) } + { headers: asBearerAuth(accessToken) }, ), }; diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 0e09a68be..d869775c9 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -538,90 +538,6 @@ describe(`${AssetController.name} (e2e)`, () => { } }); - describe('GET /asset/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get(`/asset/${uuidStub.notFound}`); - expect(body).toEqual(errorStub.unauthorized); - expect(status).toBe(401); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(server) - .get(`/asset/${uuidStub.invalid}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); - }); - - it('should require access', async () => { - const { status, body } = await request(server) - .get(`/asset/${asset4.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should get the asset info', async () => { - const { status, body } = await request(server) - .get(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); - }); - - it('should work with a shared link', async () => { - const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id], - }); - - const { status, body } = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`); - expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); - }); - - it('should not send people data for shared links for un-authenticated users', 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), - }, - ]); - - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ isFavorite: true }); - expect(status).toEqual(200); - expect(body).toMatchObject({ - id: asset1.id, - isFavorite: true, - people: [ - { - birthDate: null, - id: expect.any(String), - isHidden: false, - name: 'Test Person', - thumbnailPath: '', - }, - ], - }); - - const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id], - }); - - const data = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`); - expect(data.status).toBe(200); - expect(data.body).toMatchObject({ people: [] }); - }); - }); - describe('POST /asset/upload', () => { it('should require authentication', async () => { const { status, body } = await request(server) @@ -759,286 +675,6 @@ describe(`${AssetController.name} (e2e)`, () => { }); }); - describe('PUT /asset/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(server) - .put(`/asset/${uuidStub.invalid}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); - }); - - it('should require access', async () => { - const { status, body } = await request(server) - .put(`/asset/${asset4.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should favorite an asset', async () => { - expect(asset1).toMatchObject({ isFavorite: false }); - - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ isFavorite: true }); - expect(body).toMatchObject({ id: asset1.id, isFavorite: true }); - expect(status).toEqual(200); - }); - - it('should archive an asset', async () => { - expect(asset1).toMatchObject({ isArchived: false }); - - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ isArchived: true }); - expect(body).toMatchObject({ id: asset1.id, isArchived: true }); - expect(status).toEqual(200); - }); - - it('should update date time original', async () => { - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); - - expect(body).toMatchObject({ - id: asset1.id, - exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }), - }); - expect(status).toEqual(200); - }); - - it('should reject invalid gps coordinates', async () => { - for (const test of [ - { latitude: 12 }, - { longitude: 12 }, - { latitude: 12, longitude: 'abc' }, - { latitude: 'abc', longitude: 12 }, - { latitude: null, longitude: 12 }, - { latitude: 12, longitude: null }, - { latitude: 91, longitude: 12 }, - { latitude: -91, longitude: 12 }, - { latitude: 12, longitude: -181 }, - { latitude: 12, longitude: 181 }, - ]) { - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .send(test) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - } - }); - - it('should update gps data', async () => { - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ latitude: 12, longitude: 12 }); - - expect(body).toMatchObject({ - id: asset1.id, - exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), - }); - expect(status).toEqual(200); - }); - - it('should set the description', async () => { - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ description: 'Test asset description' }); - expect(body).toMatchObject({ - id: asset1.id, - exifInfo: expect.objectContaining({ description: 'Test asset description' }), - }); - expect(status).toEqual(200); - }); - - it('should return tagged people', 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), - }, - ]); - - const { status, body } = await request(server) - .put(`/asset/${asset1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ isFavorite: true }); - expect(status).toEqual(200); - expect(body).toMatchObject({ - id: asset1.id, - isFavorite: true, - people: [ - { - birthDate: null, - id: expect.any(String), - isHidden: false, - name: 'Test Person', - thumbnailPath: '', - }, - ], - }); - }); - }); - - describe('GET /asset/statistics', () => { - beforeEach(async () => { - await api.assetApi.upload(server, user1.accessToken, 'favored_asset', { isFavorite: true }); - await api.assetApi.upload(server, user1.accessToken, 'archived_asset', { isArchived: true }); - await api.assetApi.upload(server, user1.accessToken, 'favored_archived_asset', { - isFavorite: true, - isArchived: true, - }); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/statistics'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return stats of all assets', async () => { - const { status, body } = await request(server) - .get('/asset/statistics') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(body).toEqual({ images: 6, videos: 1, total: 7 }); - expect(status).toBe(200); - }); - - it('should return stats of all favored assets', async () => { - const { status, body } = await request(server) - .get('/asset/statistics') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isFavorite: true }); - - expect(status).toBe(200); - expect(body).toEqual({ images: 2, videos: 1, total: 3 }); - }); - - it('should return stats of all archived assets', async () => { - const { status, body } = await request(server) - .get('/asset/statistics') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isArchived: true }); - - expect(status).toBe(200); - expect(body).toEqual({ images: 3, videos: 0, total: 3 }); - }); - - it('should return stats of all favored and archived assets', async () => { - const { status, body } = await request(server) - .get('/asset/statistics') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isFavorite: true, isArchived: true }); - - expect(status).toBe(200); - expect(body).toEqual({ images: 1, videos: 0, total: 1 }); - }); - - it('should return stats of all assets neither favored nor archived', async () => { - const { status, body } = await request(server) - .get('/asset/statistics') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isFavorite: false, isArchived: false }); - - expect(status).toBe(200); - expect(body).toEqual({ images: 2, videos: 0, total: 2 }); - }); - }); - - describe('GET /asset/random', () => { - beforeAll(async () => { - await Promise.all([ - createAsset(user1, new Date('1970-02-01')), - createAsset(user1, new Date('1970-02-01')), - createAsset(user1, new Date('1970-02-01')), - createAsset(user1, new Date('1970-02-01')), - createAsset(user1, new Date('1970-02-01')), - createAsset(user1, new Date('1970-02-01')), - ]); - }); - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/random'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it.each(Array(10))('should return 1 random assets', async () => { - const { status, body } = await request(server) - .get('/asset/random') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - - const assets: AssetResponseDto[] = body; - expect(assets.length).toBe(1); - expect(assets[0].ownerId).toBe(user1.userId); - // - // assets owned by user2 - expect(assets[0].id).not.toBe(asset4.id); - // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); - }); - - it.each(Array(10))('should return 2 random assets', async () => { - const { status, body } = await request(server) - .get('/asset/random?count=2') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - - const assets: AssetResponseDto[] = body; - expect(assets.length).toBe(2); - - for (const asset of assets) { - expect(asset.ownerId).toBe(user1.userId); - // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id); - // assets owned by user2 - expect(asset.id).not.toBe(asset4.id); - } - }); - - it.each(Array(10))( - 'should return 1 asset if there are 10 assets in the database but user 2 only has 1', - async () => { - const { status, body } = await request(server) - .get('/[]asset/random') - .set('Authorization', `Bearer ${user2.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset4.id })]); - }, - ); - - it('should return error', async () => { - const { status } = await request(server) - .get('/asset/random?count=ABC') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(400); - }); - }); - describe('GET /asset/time-buckets', () => { it('should require authentication', async () => { const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH }); diff --git a/server/e2e/client/asset-api.ts b/server/e2e/client/asset-api.ts index 7dd47e06c..8d2a1b79b 100644 --- a/server/e2e/client/asset-api.ts +++ b/server/e2e/client/asset-api.ts @@ -1,4 +1,4 @@ -import { AssetBulkDeleteDto, AssetResponseDto } from '@app/domain'; +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'; @@ -74,8 +74,4 @@ export const assetApi = { expect(status).toBe(200); return body; }, - delete: async (server: any, accessToken: string, dto: AssetBulkDeleteDto) => { - const { status } = await request(server).delete('/asset').set('Authorization', `Bearer ${accessToken}`).send(dto); - expect(status).toBe(204); - }, }; diff --git a/server/e2e/jobs/specs/trash.e2e-spec.ts b/server/e2e/jobs/specs/trash.e2e-spec.ts deleted file mode 100644 index 5c4b3e905..000000000 --- a/server/e2e/jobs/specs/trash.e2e-spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { LoginResponseDto } from '@app/domain'; -import { api } from 'e2e/client'; -import { readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import type { App } from 'supertest/types'; -import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils'; - -const assetFilePath = join(IMMICH_TEST_ASSET_PATH, 'formats/png/density_plot.png'); - -describe(`Trash (e2e)`, () => { - let server: App; - let admin: LoginResponseDto; - - beforeAll(async () => { - const app = await testApp.create(); - server = app.getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - it('should move an asset to trash', async () => { - const content = await readFile(assetFilePath); - const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(assetFilePath), - }); - - const uploadedAsset = await api.assetApi.get(server, admin.accessToken, assetId); - expect(uploadedAsset.isTrashed).toBe(false); - - await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] }); - - const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId); - expect(deletedAsset.isTrashed).toBe(true); - }); - - it('should delete all trashed assets', async () => { - const content = await readFile(assetFilePath); - const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(assetFilePath), - }); - - await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] }); - - const assetsBeforeEmpty = await api.assetApi.getAllAssets(server, admin.accessToken); - expect(assetsBeforeEmpty.length).toBe(1); - - await api.trashApi.empty(server, admin.accessToken); - - const assetsAfterEmpty = await api.assetApi.getAllAssets(server, admin.accessToken); - expect(assetsAfterEmpty.length).toBe(0); - }); - - it('should restore all trashed assets', async () => { - const content = await readFile(assetFilePath); - const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(assetFilePath), - }); - - await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] }); - - const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId); - expect(deletedAsset.isTrashed).toBe(true); - - await api.trashApi.restore(server, admin.accessToken); - - const restoredAsset = await api.assetApi.get(server, admin.accessToken, assetId); - expect(restoredAsset.isTrashed).toBe(false); - }); -});