mirror of
https://github.com/immich-app/immich.git
synced 2025-01-27 22:22:45 +01:00
refactor(server): format and metadata e2e (#7477)
* refactor(server): format and metadata e2e * refactor: on upload success waiting
This commit is contained in:
parent
e2c0945bc1
commit
74d431f881
9 changed files with 509 additions and 322 deletions
62
e2e/package-lock.json
generated
62
e2e/package-lock.json
generated
|
@ -17,6 +17,7 @@
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^1.3.0",
|
"@vitest/coverage-v8": "^1.3.0",
|
||||||
|
"exiftool-vendored": "^24.5.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
|
@ -594,6 +595,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.41.2",
|
"version": "1.41.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
|
||||||
|
@ -1074,6 +1081,15 @@
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"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"
|
"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": {
|
"node_modules/fast-safe-stringify": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
@ -1584,6 +1637,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/hexoid": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^1.3.0",
|
"@vitest/coverage-v8": "^1.3.0",
|
||||||
|
"exiftool-vendored": "^24.5.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
|
|
|
@ -1,16 +1,39 @@
|
||||||
import {
|
import {
|
||||||
AssetFileUploadResponseDto,
|
AssetFileUploadResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
|
AssetTypeEnum,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SharedLinkType,
|
SharedLinkType,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
|
import { exiftool } from 'exiftool-vendored';
|
||||||
import { DateTime } from 'luxon';
|
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 { Socket } from 'socket.io-client';
|
||||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||||
import { errorDto } from 'src/responses';
|
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 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({
|
const today = DateTime.fromObject({
|
||||||
year: 2023,
|
year: 2023,
|
||||||
|
@ -24,25 +47,36 @@ describe('/asset', () => {
|
||||||
let user1: LoginResponseDto;
|
let user1: LoginResponseDto;
|
||||||
let user2: LoginResponseDto;
|
let user2: LoginResponseDto;
|
||||||
let userStats: LoginResponseDto;
|
let userStats: LoginResponseDto;
|
||||||
let asset1: AssetFileUploadResponseDto;
|
let user1Assets: AssetFileUploadResponseDto[];
|
||||||
let asset2: AssetFileUploadResponseDto;
|
let user2Assets: AssetFileUploadResponseDto[];
|
||||||
let asset3: AssetFileUploadResponseDto;
|
let assetLocation: AssetFileUploadResponseDto;
|
||||||
let asset4: AssetFileUploadResponseDto; // user2 asset
|
|
||||||
let asset5: AssetFileUploadResponseDto;
|
|
||||||
let asset6: AssetFileUploadResponseDto;
|
|
||||||
let ws: Socket;
|
let ws: Socket;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
apiUtils.setup();
|
apiUtils.setup();
|
||||||
await dbUtils.reset();
|
await dbUtils.reset();
|
||||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
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.user1),
|
||||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
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(user1.accessToken),
|
apiUtils.createAsset(user1.accessToken),
|
||||||
apiUtils.createAsset(
|
apiUtils.createAsset(
|
||||||
|
@ -56,10 +90,13 @@ describe('/asset', () => {
|
||||||
},
|
},
|
||||||
{ filename: 'example.mp4' },
|
{ filename: 'example.mp4' },
|
||||||
),
|
),
|
||||||
apiUtils.createAsset(user2.accessToken),
|
|
||||||
apiUtils.createAsset(user1.accessToken),
|
apiUtils.createAsset(user1.accessToken),
|
||||||
apiUtils.createAsset(user1.accessToken),
|
apiUtils.createAsset(user1.accessToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
// stats
|
// stats
|
||||||
apiUtils.createAsset(userStats.accessToken),
|
apiUtils.createAsset(userStats.accessToken),
|
||||||
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
|
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
|
||||||
|
@ -77,7 +114,14 @@ describe('/asset', () => {
|
||||||
const person1 = await apiUtils.createPerson(user1.accessToken, {
|
const person1 = await apiUtils.createPerson(user1.accessToken, {
|
||||||
name: 'Test Person',
|
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', () => {
|
describe('GET /asset/:id', () => {
|
||||||
|
@ -99,7 +143,7 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should require access', async () => {
|
it('should require access', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/asset/${asset4.id}`)
|
.get(`/asset/${user2Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.noPermission);
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
|
@ -107,33 +151,33 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should get the asset info', async () => {
|
it('should get the asset info', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/asset/${asset1.id}`)
|
.get(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(status).toBe(200);
|
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 () => {
|
it('should work with a shared link', async () => {
|
||||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Individual,
|
type: SharedLinkType.Individual,
|
||||||
assetIds: [asset1.id],
|
assetIds: [user1Assets[0].id],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { status, body } = await request(app).get(
|
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(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 () => {
|
it('should not send people data for shared links for un-authenticated users', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get(`/asset/${asset1.id}`)
|
.get(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
|
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: asset1.id,
|
id: user1Assets[0].id,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
people: [
|
people: [
|
||||||
{
|
{
|
||||||
|
@ -148,11 +192,11 @@ describe('/asset', () => {
|
||||||
|
|
||||||
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Individual,
|
type: SharedLinkType.Individual,
|
||||||
assetIds: [asset1.id],
|
assetIds: [user1Assets[0].id],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await request(app).get(
|
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.status).toBe(200);
|
||||||
expect(data.body).toMatchObject({ people: [] });
|
expect(data.body).toMatchObject({ people: [] });
|
||||||
|
@ -246,11 +290,11 @@ describe('/asset', () => {
|
||||||
const assets: AssetResponseDto[] = body;
|
const assets: AssetResponseDto[] = body;
|
||||||
expect(assets.length).toBe(1);
|
expect(assets.length).toBe(1);
|
||||||
expect(assets[0].ownerId).toBe(user1.userId);
|
expect(assets[0].ownerId).toBe(user1.userId);
|
||||||
//
|
|
||||||
// assets owned by user2
|
|
||||||
expect(assets[0].id).not.toBe(asset4.id);
|
|
||||||
// assets owned by user1
|
// 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 () => {
|
it.each(Array(10))('should return 2 random assets', async () => {
|
||||||
|
@ -266,9 +310,9 @@ describe('/asset', () => {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
expect(asset.ownerId).toBe(user1.userId);
|
expect(asset.ownerId).toBe(user1.userId);
|
||||||
// assets owned by user1
|
// 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
|
// 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}`);
|
.set('Authorization', `Bearer ${user2.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
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 () => {
|
it('should require access', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset4.id}`)
|
.put(`/asset/${user2Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.noPermission);
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should favorite an asset', async () => {
|
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);
|
expect(before.isFavorite).toBe(false);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ isFavorite: true });
|
.send({ isFavorite: true });
|
||||||
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
|
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should archive an asset', async () => {
|
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);
|
expect(before.isArchived).toBe(false);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ isArchived: true });
|
.send({ isArchived: true });
|
||||||
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
|
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update date time original', async () => {
|
it('should update date time original', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: asset1.id,
|
id: user1Assets[0].id,
|
||||||
exifInfo: expect.objectContaining({
|
exifInfo: expect.objectContaining({
|
||||||
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
|
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
|
||||||
}),
|
}),
|
||||||
|
@ -371,7 +423,7 @@ describe('/asset', () => {
|
||||||
{ latitude: 12, longitude: 181 },
|
{ latitude: 12, longitude: 181 },
|
||||||
]) {
|
]) {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.send(test)
|
.send(test)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
|
@ -381,12 +433,12 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should update gps data', async () => {
|
it('should update gps data', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ latitude: 12, longitude: 12 });
|
.send({ latitude: 12, longitude: 12 });
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: asset1.id,
|
id: user1Assets[0].id,
|
||||||
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
|
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
|
||||||
});
|
});
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
|
@ -394,11 +446,11 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should set the description', async () => {
|
it('should set the description', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ description: 'Test asset description' });
|
.send({ description: 'Test asset description' });
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: asset1.id,
|
id: user1Assets[0].id,
|
||||||
exifInfo: expect.objectContaining({
|
exifInfo: expect.objectContaining({
|
||||||
description: 'Test asset description',
|
description: 'Test asset description',
|
||||||
}),
|
}),
|
||||||
|
@ -408,12 +460,12 @@ describe('/asset', () => {
|
||||||
|
|
||||||
it('should return tagged people', async () => {
|
it('should return tagged people', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/asset/${asset1.id}`)
|
.put(`/asset/${user1Assets[0].id}`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ isFavorite: true });
|
.send({ isFavorite: true });
|
||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: asset1.id,
|
id: user1Assets[0].id,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
people: [
|
people: [
|
||||||
{
|
{
|
||||||
|
@ -478,4 +530,279 @@ describe('/asset', () => {
|
||||||
expect(after.isTrashed).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -29,14 +29,14 @@ describe('/audit', () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
deleteAssets(
|
deleteAssets(
|
||||||
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
|
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
|
||||||
{ headers: asBearerAuth(admin.accessToken) }
|
{ headers: asBearerAuth(admin.accessToken) },
|
||||||
),
|
),
|
||||||
updateAsset(
|
updateAsset(
|
||||||
{
|
{
|
||||||
id: archivedAsset.id,
|
id: archivedAsset.id,
|
||||||
updateAssetDto: { isArchived: true },
|
updateAssetDto: { isArchived: true },
|
||||||
},
|
},
|
||||||
{ headers: asBearerAuth(admin.accessToken) }
|
{ headers: asBearerAuth(admin.accessToken) },
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ describe('/trash', () => {
|
||||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
expect(status).toBe(204);
|
expect(status).toBe(204);
|
||||||
|
|
||||||
await wsUtils.once(ws, 'on_asset_delete');
|
await wsUtils.waitForEvent({ event: 'delete', assetId });
|
||||||
|
|
||||||
const after = await getAllAssets(
|
const after = await getAllAssets(
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process';
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
let _resolve: () => unknown;
|
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' });
|
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
const input = data.toString();
|
const input = data.toString();
|
||||||
console.log(input);
|
console.log(input);
|
||||||
if (input.includes('Immich Server is listening')) {
|
if (input.includes('Immich Microservices is listening')) {
|
||||||
_resolve();
|
_resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (data) => console.log(data.toString()));
|
child.stderr.on('data', (data) => console.log(data.toString()));
|
||||||
|
|
||||||
await promise;
|
await ready;
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await new Promise<void>((resolve) =>
|
await new Promise<void>((resolve) =>
|
||||||
exec('docker compose down', () => resolve())
|
exec('docker compose down', () => resolve()),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
AssetFileUploadResponseDto,
|
AssetFileUploadResponseDto,
|
||||||
|
AssetResponseDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateAssetDto,
|
CreateAssetDto,
|
||||||
CreateUserDto,
|
CreateUserDto,
|
||||||
|
@ -19,10 +20,12 @@ import {
|
||||||
updatePerson,
|
updatePerson,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { exec, spawn } from 'child_process';
|
import { exec, spawn } from 'node:child_process';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { access } from 'node:fs/promises';
|
import { access } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { EventEmitter } from 'node:stream';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
@ -40,6 +43,7 @@ const directoryExists = (directory: string) =>
|
||||||
|
|
||||||
// TODO move test assets into e2e/assets
|
// TODO move test assets into e2e/assets
|
||||||
export const testAssetDir = path.resolve(`./../server/test/assets/`);
|
export const testAssetDir = path.resolve(`./../server/test/assets/`);
|
||||||
|
export const tempDir = tmpdir();
|
||||||
|
|
||||||
const serverContainerName = 'immich-e2e-server';
|
const serverContainerName = 'immich-e2e-server';
|
||||||
const mediaDir = '/usr/src/app/upload';
|
const mediaDir = '/usr/src/app/upload';
|
||||||
|
@ -47,6 +51,7 @@ const dirs = [
|
||||||
`"${mediaDir}/thumbs"`,
|
`"${mediaDir}/thumbs"`,
|
||||||
`"${mediaDir}/upload"`,
|
`"${mediaDir}/upload"`,
|
||||||
`"${mediaDir}/library"`,
|
`"${mediaDir}/library"`,
|
||||||
|
`"${mediaDir}/encoded-video"`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
if (!(await directoryExists(`${testAssetDir}/albums`))) {
|
if (!(await directoryExists(`${testAssetDir}/albums`))) {
|
||||||
|
@ -177,33 +182,85 @@ export interface AdminSetupOptions {
|
||||||
onboarding?: boolean;
|
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 = {
|
export const wsUtils = {
|
||||||
connect: async (accessToken: string) => {
|
connect: async (accessToken: string) => {
|
||||||
const websocket = io('http://127.0.0.1:2283', {
|
const websocket = io('http://127.0.0.1:2283', {
|
||||||
path: '/api/socket.io',
|
path: '/api/socket.io',
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
extraHeaders: { Authorization: `Bearer ${accessToken}` },
|
extraHeaders: { Authorization: `Bearer ${accessToken}` },
|
||||||
autoConnect: false,
|
autoConnect: true,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Promise<Socket>((resolve) => {
|
return new Promise<Socket>((resolve) => {
|
||||||
websocket.on('connect', () => resolve(websocket));
|
websocket
|
||||||
websocket.connect();
|
.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) => {
|
disconnect: (ws: Socket) => {
|
||||||
if (ws?.connected) {
|
if (ws?.connected) {
|
||||||
ws.disconnect();
|
ws.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const set of Object.values(events)) {
|
||||||
|
set.clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
once: <T = any>(ws: Socket, event: string): Promise<T> => {
|
waitForEvent: async ({
|
||||||
return new Promise<T>((resolve, reject) => {
|
event,
|
||||||
const timeout = setTimeout(() => reject(new Error('Timeout')), 4000);
|
assetId,
|
||||||
ws.once(event, (data: T) => {
|
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);
|
clearTimeout(timeout);
|
||||||
resolve(data);
|
resolve();
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
Reference in a new issue