diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 649893ec89..edf5a1a09d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -17,6 +17,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", @@ -594,6 +595,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@photostructure/tz-lookup": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz", + "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==", + "dev": true + }, "node_modules/@playwright/test": { "version": "1.41.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", @@ -1074,6 +1081,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/batch-cluster": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", + "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1391,6 +1407,43 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exiftool-vendored": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", + "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "dev": true, + "dependencies": { + "@photostructure/tz-lookup": "^9.0.1", + "@types/luxon": "^3.4.2", + "batch-cluster": "^13.0.0", + "he": "^1.2.0", + "luxon": "^3.4.4" + }, + "optionalDependencies": { + "exiftool-vendored.exe": "12.76.0", + "exiftool-vendored.pl": "12.76.0" + } + }, + "node_modules/exiftool-vendored.exe": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", + "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/exiftool-vendored.pl": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", + "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", + "dev": true, + "optional": true, + "os": [ + "!win32" + ] + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1584,6 +1637,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", diff --git a/e2e/package.json b/e2e/package.json index 7bbdfd1d9d..26a1d7ef3a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,6 +21,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index db1821260b..e1f4450312 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1,16 +1,39 @@ import { AssetFileUploadResponseDto, AssetResponseDto, + AssetTypeEnum, LoginResponseDto, SharedLinkType, } from '@immich/sdk'; +import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; +import { createHash } 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 { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { + apiUtils, + app, + dbUtils, + tempDir, + testAssetDir, + wsUtils, +} from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; + +const sha1 = (bytes: Buffer) => + createHash('sha1').update(bytes).digest('base64'); + +const readTags = async (bytes: Buffer, filename: string) => { + const filepath = join(tempDir, filename); + await writeFile(filepath, bytes); + return exiftool.read(filepath); +}; const today = DateTime.fromObject({ year: 2023, @@ -24,25 +47,36 @@ describe('/asset', () => { 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 user1Assets: AssetFileUploadResponseDto[]; + let user2Assets: AssetFileUploadResponseDto[]; + let assetLocation: AssetFileUploadResponseDto; let ws: Socket; beforeAll(async () => { apiUtils.setup(); await dbUtils.reset(); admin = await apiUtils.adminSetup({ onboarding: false }); - [user1, user2, userStats] = await Promise.all([ + + [ws, user1, user2, userStats] = await Promise.all([ + wsUtils.connect(admin.accessToken), 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([ + // asset location + assetLocation = await apiUtils.createAsset( + admin.accessToken, + {}, + { + filename: 'thompson-springs.jpg', + bytes: await readFile(locationAssetFilepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id }); + + user1Assets = await Promise.all([ apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset( @@ -56,10 +90,13 @@ describe('/asset', () => { }, { filename: 'example.mp4' }, ), - apiUtils.createAsset(user2.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), + ]); + user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]); + + await Promise.all([ // stats apiUtils.createAsset(userStats.accessToken), apiUtils.createAsset(userStats.accessToken, { isFavorite: true }), @@ -77,7 +114,14 @@ describe('/asset', () => { const person1 = await apiUtils.createPerson(user1.accessToken, { name: 'Test Person', }); - await dbUtils.createFace({ assetId: asset1.id, personId: person1.id }); + await dbUtils.createFace({ + assetId: user1Assets[0].id, + personId: person1.id, + }); + }, 30_000); + + afterAll(() => { + wsUtils.disconnect(ws); }); describe('GET /asset/:id', () => { @@ -99,7 +143,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .get(`/asset/${asset4.id}`) + .get(`/asset/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); @@ -107,33 +151,33 @@ describe('/asset', () => { it('should get the asset info', async () => { const { status, body } = await request(app) - .get(`/asset/${asset1.id}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should work with a shared link', async () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const { status, body } = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].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}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: false, people: [ { @@ -148,11 +192,11 @@ describe('/asset', () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const data = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(data.status).toBe(200); expect(data.body).toMatchObject({ people: [] }); @@ -246,11 +290,11 @@ describe('/asset', () => { 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); + expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id); + // assets owned by user2 + expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id); }); it.each(Array(10))('should return 2 random assets', async () => { @@ -266,9 +310,9 @@ describe('/asset', () => { 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); + expect([user1Assets.map(({ id }) => id)]).toContain(asset.id); // assets owned by user2 - expect(asset.id).not.toBe(asset4.id); + expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id); } }); @@ -280,7 +324,9 @@ describe('/asset', () => { .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset4.id })]); + expect(body).toEqual([ + expect.objectContaining({ id: user2Assets[0].id }), + ]); }, ); @@ -312,44 +358,50 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .put(`/asset/${asset4.id}`) + .put(`/asset/${user2Assets[0].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); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isFavorite).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); - expect(body).toMatchObject({ id: asset1.id, isFavorite: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true }); expect(status).toEqual(200); }); it('should archive an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isArchived).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isArchived: true }); - expect(body).toMatchObject({ id: asset1.id, isArchived: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); expect(status).toEqual(200); }); it('should update date time original', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z', }), @@ -371,7 +423,7 @@ describe('/asset', () => { { latitude: 12, longitude: 181 }, ]) { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .send(test) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -381,12 +433,12 @@ describe('/asset', () => { it('should update gps data', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ latitude: 12, longitude: 12 }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), }); expect(status).toEqual(200); @@ -394,11 +446,11 @@ describe('/asset', () => { it('should set the description', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'Test asset description' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ description: 'Test asset description', }), @@ -408,12 +460,12 @@ describe('/asset', () => { it('should return tagged people', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: true, people: [ { @@ -478,4 +530,279 @@ describe('/asset', () => { expect(after.isTrashed).toBe(true); }); }); + + describe('POST /asset/upload', () => { + const tests = [ + { + input: 'formats/jpg/el_torcal_rocks.jpg', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'el_torcal_rocks', + resized: true, + exifInfo: { + dateTimeOriginal: '2012-08-05T11:39:59.000Z', + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + focalLength: 75, + iso: 200, + fNumber: 11, + exposureTime: '1/160', + fileSizeInByte: 53_493, + make: 'SONY', + model: 'DSLR-A550', + orientation: null, + description: 'SONY DSC', + }, + }, + }, + { + input: 'formats/heic/IMG_2682.heic', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'IMG_2682', + resized: true, + fileCreatedAt: '2019-03-21T16:04:22.348Z', + exifInfo: { + dateTimeOriginal: '2019-03-21T16:04:22.348Z', + exifImageWidth: 4032, + exifImageHeight: 3024, + latitude: 41.2203, + longitude: -96.071_625, + make: 'Apple', + model: 'iPhone 7', + lensModel: 'iPhone 7 back camera 3.99mm f/1.8', + fileSizeInByte: 880_703, + exposureTime: '1/887', + iso: 20, + focalLength: 3.99, + fNumber: 1.8, + timeZone: 'America/Chicago', + }, + }, + }, + { + input: 'formats/png/density_plot.png', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'density_plot', + resized: true, + exifInfo: { + exifImageWidth: 800, + exifImageHeight: 800, + latitude: null, + longitude: null, + fileSizeInByte: 25_408, + }, + }, + }, + { + input: 'formats/raw/Nikon/D80/glarus.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'glarus', + resized: true, + fileCreatedAt: '2010-07-20T17:27:12.000Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D80', + exposureTime: '1/200', + fNumber: 10, + focalLength: 18, + iso: 100, + fileSizeInByte: 9_057_784, + dateTimeOriginal: '2010-07-20T17:27:12.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Nikon/D700/philadelphia.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'philadelphia', + resized: true, + fileCreatedAt: '2016-09-22T22:10:29.060Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D700', + exposureTime: '1/400', + fNumber: 11, + focalLength: 85, + iso: 200, + fileSizeInByte: 15_856_335, + dateTimeOriginal: '2016-09-22T22:10:29.060Z', + latitude: null, + longitude: null, + orientation: '1', + timeZone: 'UTC-5', + }, + }, + }, + ]; + + for (const { input, expected } of tests) { + it(`should generate a thumbnail for ${input}`, async () => { + const filepath = join(testAssetDir, input); + const { id, duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { bytes: await readFile(filepath), filename: basename(filepath) }, + ); + + expect(duplicate).toBe(false); + + await wsUtils.waitForEvent({ event: 'upload', assetId: id }); + + const asset = await apiUtils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + }); + } + + it('should handle a duplicate', async () => { + const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; + const { duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + expect(duplicate).toBe(true); + }); + + // 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. + // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives + // into the test here. + const motionTests = [ + { + filepath: 'formats/motionphoto/Samsung One UI 5.jpg', + checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.jpg', + checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.heic', + checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=', + }, + ]; + + for (const { filepath, checksum } of motionTests) { + it(`should extract motionphoto video from ${filepath}`, async () => { + const response = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: response.id }); + + expect(response.duplicate).toBe(false); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + response.id, + ); + expect(asset.livePhotoVideoId).toBeDefined(); + + const video = await apiUtils.getAssetInfo( + admin.accessToken, + asset.livePhotoVideoId as string, + ); + expect(video.checksum).toStrictEqual(checksum); + }); + } + }); + + describe('GET /asset/thumbnail/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not include gps data for webp thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + await wsUtils.waitForEvent({ + event: 'upload', + assetId: assetLocation.id, + }); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/webp'); + + const exifData = await readTags(body, 'thumbnail.webp'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + + it('should not include gps data for jpeg thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const exifData = await readTags(body, 'thumbnail.jpg'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + }); + + describe('GET /asset/file/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download the original', async () => { + const { status, body, type } = await request(app) + .get(`/asset/file/${assetLocation.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + assetLocation.id, + ); + + const original = await readFile(locationAssetFilepath); + const originalChecksum = sha1(original); + const downloadChecksum = sha1(body); + + expect(originalChecksum).toBe(downloadChecksum); + expect(downloadChecksum).toBe(asset.checksum); + }); + }); }); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts index 073106e728..0bc8e6b173 100644 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -29,14 +29,14 @@ describe('/audit', () => { await Promise.all([ deleteAssets( { assetBulkDeleteDto: { ids: [trashedAsset.id] } }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), updateAsset( { id: archivedAsset.id, updateAssetDto: { isArchived: true }, }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), ]); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 2de838f981..cb4a8b9dd1 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -44,7 +44,7 @@ describe('/trash', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await wsUtils.once(ws, 'on_asset_delete'); + await wsUtils.waitForEvent({ event: 'delete', assetId }); const after = await getAllAssets( {}, diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index b560a2bbb1..04e8d79ac5 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process'; export default async () => { let _resolve: () => unknown; - const promise = new Promise<void>((resolve) => (_resolve = resolve)); + const ready = new Promise<void>((resolve) => (_resolve = resolve)); const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); console.log(input); - if (input.includes('Immich Server is listening')) { + if (input.includes('Immich Microservices is listening')) { _resolve(); } }); child.stderr.on('data', (data) => console.log(data.toString())); - await promise; + await ready; return async () => { await new Promise<void>((resolve) => - exec('docker compose down', () => resolve()) + exec('docker compose down', () => resolve()), ); }; }; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 428c88b454..4261e8f67d 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,6 @@ import { AssetFileUploadResponseDto, + AssetResponseDto, CreateAlbumDto, CreateAssetDto, CreateUserDto, @@ -19,10 +20,12 @@ import { updatePerson, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; -import { exec, spawn } from 'child_process'; +import { exec, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; +import { EventEmitter } from 'node:stream'; import { promisify } from 'node:util'; import pg from 'pg'; import { io, type Socket } from 'socket.io-client'; @@ -40,6 +43,7 @@ const directoryExists = (directory: string) => // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); +export const tempDir = tmpdir(); const serverContainerName = 'immich-e2e-server'; const mediaDir = '/usr/src/app/upload'; @@ -47,6 +51,7 @@ const dirs = [ `"${mediaDir}/thumbs"`, `"${mediaDir}/upload"`, `"${mediaDir}/library"`, + `"${mediaDir}/encoded-video"`, ].join(' '); if (!(await directoryExists(`${testAssetDir}/albums`))) { @@ -177,33 +182,85 @@ export interface AdminSetupOptions { onboarding?: boolean; } +export enum SocketEvent { + UPLOAD = 'upload', + DELETE = 'delete', +} + +export type EventType = 'upload' | 'delete'; +export interface WaitOptions { + event: EventType; + assetId: string; + timeout?: number; +} + +const events: Record<EventType, Set<string>> = { + upload: new Set<string>(), + delete: new Set<string>(), +}; + +const callbacks: Record<string, () => void> = {}; + +const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { + events[event].add(assetId); + const callback = callbacks[assetId]; + if (callback) { + callback(); + delete callbacks[assetId]; + } +}; + 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, + autoConnect: true, forceNew: true, }); return new Promise<Socket>((resolve) => { - websocket.on('connect', () => resolve(websocket)); - websocket.connect(); + websocket + .on('connect', () => resolve(websocket)) + .on('on_upload_success', (data: AssetResponseDto) => + onEvent({ event: 'upload', assetId: data.id }), + ) + .on('on_asset_delete', (assetId: string) => + onEvent({ event: 'delete', assetId }), + ) + .connect(); }); }, disconnect: (ws: Socket) => { if (ws?.connected) { ws.disconnect(); } + + for (const set of Object.values(events)) { + set.clear(); + } }, - once: <T = any>(ws: Socket, event: string): Promise<T> => { - return new Promise<T>((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timeout')), 4000); - ws.once(event, (data: T) => { + waitForEvent: async ({ + event, + assetId, + timeout: ms, + }: WaitOptions): Promise<void> => { + const set = events[event]; + if (set.has(assetId)) { + return; + } + + return new Promise<void>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${event} event`)), + ms || 5000, + ); + + callbacks[assetId] = () => { clearTimeout(timeout); - resolve(data); - }); + resolve(); + }; }); }, }; diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts deleted file mode 100644 index c8b14d588a..0000000000 --- a/server/e2e/jobs/specs/formats.e2e-spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { LoginResponseDto } from '@app/domain'; -import { AssetType } from '@app/infra/entities'; -import { readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -const JPEG = { - type: AssetType.IMAGE, - originalFileName: 'el_torcal_rocks', - resized: true, - exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - focalLength: 75, - iso: 200, - fNumber: 11, - exposureTime: '1/160', - fileSizeInByte: 53_493, - make: 'SONY', - model: 'DSLR-A550', - orientation: null, - description: 'SONY DSC', - }, -}; - -const tests = [ - { input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG }, - { input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG }, - { - input: 'formats/heic/IMG_2682.heic', - expected: { - type: AssetType.IMAGE, - originalFileName: 'IMG_2682', - resized: true, - fileCreatedAt: '2019-03-21T16:04:22.348Z', - exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', - exifImageWidth: 4032, - exifImageHeight: 3024, - latitude: 41.2203, - longitude: -96.071_625, - make: 'Apple', - model: 'iPhone 7', - lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880_703, - exposureTime: '1/887', - iso: 20, - focalLength: 3.99, - fNumber: 1.8, - timeZone: 'America/Chicago', - }, - }, - }, - { - input: 'formats/png/density_plot.png', - expected: { - type: AssetType.IMAGE, - originalFileName: 'density_plot', - resized: true, - exifInfo: { - exifImageWidth: 800, - exifImageHeight: 800, - latitude: null, - longitude: null, - fileSizeInByte: 25_408, - }, - }, - }, - { - input: 'formats/raw/Nikon/D80/glarus.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'glarus', - resized: true, - fileCreatedAt: '2010-07-20T17:27:12.000Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D80', - exposureTime: '1/200', - fNumber: 10, - focalLength: 18, - iso: 100, - fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Nikon/D700/philadelphia.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'philadelphia', - resized: true, - fileCreatedAt: '2016-09-22T22:10:29.060Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D700', - exposureTime: '1/400', - fNumber: 11, - focalLength: 85, - iso: 200, - fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', - latitude: null, - longitude: null, - orientation: '1', - timeZone: 'UTC-5', - }, - }, - }, -]; - -describe(`Format (e2e)`, () => { - let server: any; - 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(); - }); - - for (const { input, expected } of tests) { - it(`should generate a thumbnail for ${input}`, async () => { - const filepath = join(IMMICH_TEST_ASSET_PATH, input); - const content = await readFile(filepath); - await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(filepath), - }); - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - - const asset = assets[0]; - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - }); - } -}); diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts deleted file mode 100644 index 5eb75fee2d..0000000000 --- a/server/e2e/jobs/specs/metadata.e2e-spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { AssetResponseDto, LoginResponseDto } from '@app/domain'; -import { AssetController } from '@app/immich'; -import { exiftool } from 'exiftool-vendored'; -import { readFile, writeFile } from 'fs/promises'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - db, - restoreTempFolder, - testApp, -} from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -describe(`${AssetController.name} (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - - beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - - describe('should strip metadata of', () => { - let assetWithLocation: AssetResponseDto; - - beforeEach(async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`); - - await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - assetWithLocation = assets[0]; - - expect(assetWithLocation).toEqual( - expect.objectContaining({ - exifInfo: expect.objectContaining({ latitude: 39.115, longitude: -108.400968333333 }), - }), - ); - }); - - it('small webp thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - - it('large jpeg thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - }); - - describe.each([ - // 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. - // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives - // into the test here. - ['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='], - ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='], - ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='], - ])('should extract motionphoto video', (file, checksum) => { - it(`with checksum ${checksum} from ${file}`, async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`); - - const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - const asset = await api.assetApi.get(server, admin.accessToken, response.id); - expect(asset).toHaveProperty('livePhotoVideoId'); - const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string); - - expect(video.checksum).toStrictEqual(checksum); - }); - }); -});