1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-11 06:10:28 +02:00

refactor(server): e2e (#6632)

This commit is contained in:
Jason Rasmussen 2024-01-24 17:24:53 -05:00 committed by GitHub
parent 4424f3cb13
commit 852effa998
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 154 additions and 485 deletions

View File

@ -19,6 +19,9 @@ pull-stage:
server-e2e-jobs:
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
server-e2e-api:
npm run e2e:api --prefix server
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

View File

@ -16,6 +16,7 @@
],
"coverageDirectory": "./coverage",
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>../server/e2e/client/$1",
"^@test(|/.*)$": "<rootDir>../server/test/$1",
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",

View File

@ -1,5 +1,5 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { api } from '@api';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { LoginKey } from 'src/commands/login/key';

View File

@ -1,5 +1,5 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { api } from '@api';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { ServerInfo } from 'src/commands/server-info';

View File

@ -1,5 +1,5 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { api } from '@api';
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { Upload } from 'src/commands/upload';

View File

@ -17,6 +17,8 @@
"rootDirs": ["src", "../server/src"],
"baseUrl": "./",
"paths": {
"@api": ["../server/e2e/client"],
"@api/*": ["../server/e2e/client/*"],
"@test": ["../server/test"],
"@test/*": ["../server/test/*"],
"@app/immich": ["../server/src/immich"],

View File

@ -8,10 +8,20 @@ Unit are run by calling `npm run test` from the `server` directory.
### End to end tests
The backend has an end-to-end test suite that can be called with `npm run test:e2e` from the `server` directory. This will set up a dummy database inside a temporary container and run the tests against it. Setup and teardown is automatically taken care of. That test, however, can not set up all prerequisites to parse file formats, as that is very complex and error-prone. As such, this test excludes some test cases like HEIC file imports. The test suite will also print a friendly warning to remind you that not all tests are being run.
The backend has two end-to-end test suites that can be called with the following two commands from the project root directory:
- `make server-e2e-api`
- `make server-e2e-jobs`
#### API (e2e)
The API e2e tests spin up a test database and execute http requests against the server, validating the expected response codes and functionality for API endpoints.
#### Jobs (e2e)
The Jobs e2e tests spin up a docker test environment where thumbnail generation, library scanning, and other _job_ workflows are validated.
:::note
Note that there is a bug in nodejs \<20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perform the tests and exit.
If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run e2e:jobs`.
:::
/follow

2
server/bin/immich-test Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env bash
NODE_OPTIONS='--experimental-vm-modules' node /usr/src/app/node_modules/.bin/jest --config e2e/$1/jest-e2e.json --runInBand

View File

@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt
import { ActivityEntity } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${ActivityController.name} (e2e)`, () => {

View File

@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt
import { SharedLinkType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
const user1SharedUser = 'user1SharedUser';

View File

@ -16,7 +16,7 @@ import { INestApplication } from '@nestjs/common';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import { randomBytes } from 'crypto';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { generateAsset, testApp, today, yesterday } from '../utils';
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {

View File

@ -9,7 +9,7 @@ import {
uuidStub,
} from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
const name = 'Immich Admin';

View File

@ -3,7 +3,7 @@ import { LibraryController } from '@app/immich';
import { LibraryType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${LibraryController.name} (e2e)`, () => {

View File

@ -1,7 +1,7 @@
import { OAuthController } from '@app/immich';
import { errorStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${OAuthController.name} (e2e)`, () => {

View File

@ -2,7 +2,7 @@ import { LoginResponseDto, PartnerDirection } from '@app/domain';
import { PartnerController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${PartnerController.name} (e2e)`, () => {

View File

@ -4,7 +4,7 @@ import { PersonEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${PersonController.name}`, () => {

View File

@ -10,7 +10,7 @@ import { SearchController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { errorStub, searchStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { generateAsset, testApp } from '../utils';
describe(`${SearchController.name}`, () => {

View File

@ -2,7 +2,7 @@ import { LoginResponseDto } from '@app/domain';
import { ServerInfoController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${ServerInfoController.name} (e2e)`, () => {

View File

@ -11,7 +11,7 @@ import { INestApplication } from '@nestjs/common';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import { DateTime } from 'luxon';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${SharedLinkController.name} (e2e)`, () => {

View File

@ -2,7 +2,7 @@ import { LoginResponseDto } from '@app/domain';
import { SystemConfigController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${SystemConfigController.name} (e2e)`, () => {

View File

@ -6,7 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest';
import { Repository } from 'typeorm';
import { api } from '../client';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${UserController.name}`, () => {

View File

@ -4,7 +4,7 @@ import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dt
import { randomBytes } from 'crypto';
import request from 'supertest';
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer; filename?: string };
const asset = {
deviceAssetId: 'test-1',
@ -45,19 +45,19 @@ export const assetApi = {
expect(status).toBe(200);
return body as AssetResponseDto[];
},
upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
const { content, isFavorite = false, isArchived = false } = dto;
upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => {
const { content, filename, isFavorite = false, isArchived = false } = dto;
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${accessToken}`)
.field('deviceAssetId', id)
.field('deviceAssetId', deviceAssetId)
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', isFavorite)
.field('isArchived', isArchived)
.field('duration', '0:00:00.000000')
.attach('assetData', content || randomBytes(32), 'example.jpg');
.attach('assetData', content || randomBytes(32), filename || 'example.jpg');
expect(status).toBe(201);
return body as AssetFileUploadResponseDto;

View File

@ -9,8 +9,7 @@ services:
context: ../../
dockerfile: server/Dockerfile
target: dev
entrypoint: ['/usr/local/bin/npm', 'run']
command: e2e:jobs
command: ['/usr/src/app/bin/immich-test', 'jobs']
volumes:
- /usr/src/app/node_modules
environment:
@ -18,7 +17,6 @@ services:
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=e2e_test
- IMMICH_RUN_ALL_TESTS=true
depends_on:
- database

View File

@ -1,79 +0,0 @@
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 'crypto';
import request from 'supertest';
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer };
const asset = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date(),
fileModifiedAt: new Date(),
};
export const assetApi = {
create: async (
server: any,
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>,
): Promise<AssetResponseDto> => {
dto = dto || asset;
const { status, body } = await request(server)
.post(`/asset/upload`)
.field('deviceAssetId', dto.deviceAssetId)
.field('deviceId', dto.deviceId)
.field('fileCreatedAt', dto.fileCreatedAt.toISOString())
.field('fileModifiedAt', dto.fileModifiedAt.toISOString())
.attach('assetData', randomBytes(32), 'example.jpg')
.set('Authorization', `Bearer ${accessToken}`);
expect([200, 201].includes(status)).toBe(true);
return body as AssetResponseDto;
},
get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => {
const { body, status } = await request(server)
.get(`/asset/assetById/${id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto;
},
getAllAssets: async (server: any, accessToken: string) => {
const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto[];
},
upload: async (server: any, accessToken: string, id: string, dto: UploadDto = {}) => {
const { content, isFavorite = false, isArchived = false } = dto;
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${accessToken}`)
.field('deviceAssetId', id)
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', isFavorite)
.field('isArchived', isArchived)
.field('duration', '0:00:00.000000')
.attach('assetData', content || randomBytes(32), 'example.jpg');
expect(status).toBe(201);
return body as AssetFileUploadResponseDto;
},
getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}?format=JPEG`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
};

View File

@ -1,45 +0,0 @@
import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
export const authApi = {
adminSignUp: async (server: any) => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(201);
return body as UserResponseDto;
},
adminLogin: async (server: any) => {
const { status, body } = await request(server).post('/auth/login').send(loginStub.admin);
expect(body).toEqual(loginResponseStub.admin.response);
expect(body).toMatchObject({ accessToken: expect.any(String) });
expect(status).toBe(201);
return body as LoginResponseDto;
},
login: async (server: any, dto: LoginCredentialDto) => {
const { status, body } = await request(server).post('/auth/login').send(dto);
expect(status).toEqual(201);
expect(body).toMatchObject({ accessToken: expect.any(String) });
return body as LoginResponseDto;
},
getAuthDevices: async (server: any, accessToken: string) => {
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
expect(body).toEqual(expect.any(Array));
expect(status).toBe(200);
return body as AuthDeviceResponseDto[];
},
validateToken: async (server: any, accessToken: string) => {
const { status, body } = await request(server)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toEqual({ authStatus: true });
expect(status).toBe(200);
},
};

View File

@ -1,11 +0,0 @@
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { userApi } from './user-api';
export const api = {
authApi,
assetApi,
libraryApi,
userApi,
};

View File

@ -1,47 +0,0 @@
import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto } from '@app/domain';
import request from 'supertest';
export const libraryApi = {
getAll: async (server: any, accessToken: string) => {
const { body, status } = await request(server).get(`/library/`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as LibraryResponseDto[];
},
create: async (server: any, accessToken: string, dto: CreateLibraryDto) => {
const { body, status } = await request(server)
.post(`/library/`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
return body as LibraryResponseDto;
},
setImportPaths: async (server: any, accessToken: string, id: string, importPaths: string[]) => {
const { body, status } = await request(server)
.put(`/library/${id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ importPaths });
expect(status).toBe(200);
return body as LibraryResponseDto;
},
scanLibrary: async (server: any, accessToken: string, id: string, dto: ScanLibraryDto = {}) => {
const { status } = await request(server)
.post(`/library/${id}/scan`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
},
removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
const { status } = await request(server)
.post(`/library/${id}/removeOffline`)
.set('Authorization', `Bearer ${accessToken}`)
.send();
expect(status).toBe(201);
},
getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
const { body, status } = await request(server)
.get(`/library/${id}/statistics`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
};

View File

@ -1,50 +0,0 @@
import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain';
import request from 'supertest';
export const userApi = {
create: async (server: any, accessToken: string, dto: CreateUserDto) => {
const { status, body } = await request(server)
.post('/user')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
email: dto.email,
});
return body as UserResponseDto;
},
get: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server)
.get(`/user/info/${id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id });
return body as UserResponseDto;
},
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(status).toBe(200);
expect(body).toMatchObject({ id: dto.id });
return body as UserResponseDto;
},
setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
return await userApi.update(server, accessToken, { id, externalPath });
},
delete: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
return body as UserResponseDto;
},
};

View File

@ -3,16 +3,6 @@ import { access } from 'fs/promises';
import path from 'path';
export default async () => {
const allTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
if (!allTests) {
console.warn(
`\n\n
*** Not running all server e2e tests. Run 'make test-e2e' to run all tests inside Docker (recommended)\n
*** or set 'IMMICH_RUN_ALL_TESTS=true' to run all tests (requires dependencies to be installed)\n`,
);
}
let IMMICH_TEST_ASSET_PATH: string = '';
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {

View File

@ -1,31 +1,15 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType, LibraryType } from '@app/infra/entities';
import { api } from '../client';
import { IMMICH_TEST_ASSET_PATH, runAllTests, testApp } from '../utils';
import { AssetType } from '@app/infra/entities';
import { readFile } from 'fs/promises';
import { basename, join } from 'path';
import { api } from '../../client';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../utils';
describe(`Supported file formats (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
interface FormatTest {
format: string;
path: string;
runTest: boolean;
expectedAsset: any;
expectedExif: any;
}
const formatTests: FormatTest[] = [
{
format: 'jpg',
path: 'jpg',
runTest: true,
expectedAsset: {
const JPEG = {
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
resized: true,
},
expectedExif: {
exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
@ -41,44 +25,19 @@ describe(`Supported file formats (e2e)`, () => {
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 },
{
format: 'jpeg',
path: 'jpeg',
runTest: true,
expectedAsset: {
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
resized: true,
},
expectedExif: {
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: 53493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
},
{
format: 'heic',
path: 'heic',
runTest: runAllTests,
expectedAsset: {
input: 'formats/heic/IMG_2682.heic',
expected: {
type: AssetType.IMAGE,
originalFileName: 'IMG_2682',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z',
},
expectedExif: {
exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
exifImageWidth: 4032,
exifImageHeight: 3024,
@ -95,16 +54,14 @@ describe(`Supported file formats (e2e)`, () => {
timeZone: 'America/Chicago',
},
},
},
{
format: 'png',
path: 'png',
runTest: true,
expectedAsset: {
input: 'formats/png/density_plot.png',
expected: {
type: AssetType.IMAGE,
originalFileName: 'density_plot',
resized: true,
},
expectedExif: {
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
@ -112,17 +69,15 @@ describe(`Supported file formats (e2e)`, () => {
fileSizeInByte: 25408,
},
},
},
{
format: 'nef (Nikon D80)',
path: 'raw/Nikon/D80',
runTest: true,
expectedAsset: {
input: 'formats/raw/Nikon/D80/glarus.nef',
expected: {
type: AssetType.IMAGE,
originalFileName: 'glarus',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z',
},
expectedExif: {
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D80',
exposureTime: '1/200',
@ -136,17 +91,15 @@ describe(`Supported file formats (e2e)`, () => {
orientation: '1',
},
},
},
{
format: 'nef (Nikon D700)',
path: 'raw/Nikon/D700',
runTest: true,
expectedAsset: {
input: 'formats/raw/Nikon/D700/philadelphia.nef',
expected: {
type: AssetType.IMAGE,
originalFileName: 'philadelphia',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z',
},
expectedExif: {
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D700',
exposureTime: '1/400',
@ -161,41 +114,45 @@ describe(`Supported file formats (e2e)`, () => {
timeZone: 'UTC-5',
},
},
];
},
];
// Only run tests with runTest = true
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
describe(`Format (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create({ jobs: true })).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
const app = await testApp.create();
server = app.getHttpServer();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
});
it.each(testsToRun)('should import file of format $format', async (testedFormat) => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/formats/${testedFormat.path}`],
afterAll(async () => {
await testApp.teardown();
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, {});
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).toEqual([
expect.objectContaining({
...testedFormat.expectedAsset,
exifInfo: expect.objectContaining(testedFormat.expectedExif),
}),
]);
expect(assets).toHaveLength(1);
const asset = assets[0];
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected);
});
}
});

View File

@ -5,7 +5,7 @@ import { errorStub, uuidStub } from '@test/fixtures';
import * as fs from 'fs';
import request from 'supertest';
import { utimes } from 'utimes';
import { api } from '../client';
import { api } from '../../client';
import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '../utils';
describe(`${LibraryController.name} (e2e)`, () => {
@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
let admin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create({ jobs: true })).getHttpServer();
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
@ -148,48 +148,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
);
});
it('should scan external library with import paths', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
libraryId: library.id,
resized: true,
exifInfo: expect.objectContaining({
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
}),
}),
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'silver_fir',
libraryId: library.id,
resized: true,
thumbhash: expect.any(String),
exifInfo: expect.objectContaining({
exifImageWidth: 511,
exifImageHeight: 323,
latitude: null,
longitude: null,
}),
}),
]),
);
});
it('should offline missing files', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,

View File

@ -2,17 +2,9 @@ import { AssetResponseDto, LoginResponseDto } from '@app/domain';
import { AssetController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import * as fs from 'fs';
import { api } from '../client';
import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
db,
itif,
restoreTempFolder,
runAllTests,
testApp,
} from '../utils';
import { readFile, writeFile } from 'fs/promises';
import { api } from '../../client';
import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, db, restoreTempFolder, testApp } from '../utils';
describe(`${AssetController.name} (e2e)`, () => {
let app: INestApplication;
@ -20,7 +12,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let admin: LoginResponseDto;
beforeAll(async () => {
app = await testApp.create({ jobs: true });
app = await testApp.create();
server = app.getHttpServer();
});
@ -40,9 +32,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let assetWithLocation: AssetResponseDto;
beforeEach(async () => {
const fileContent = await fs.promises.readFile(
`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`,
);
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 });
@ -58,12 +48,12 @@ describe(`${AssetController.name} (e2e)`, () => {
);
});
itif(runAllTests)('small webp thumbnails', async () => {
it('small webp thumbnails', async () => {
const assetId = assetWithLocation.id;
const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId);
await fs.promises.writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail);
await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail);
const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`);
@ -71,12 +61,12 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(exifData).not.toHaveProperty('GPSLatitude');
});
itif(runAllTests)('large jpeg thumbnails', async () => {
it('large jpeg thumbnails', async () => {
const assetId = assetWithLocation.id;
const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId);
await fs.promises.writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail);
await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail);
const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`);
@ -95,8 +85,8 @@ describe(`${AssetController.name} (e2e)`, () => {
['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='],
['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='],
])('should extract motionphoto video', (file, checksum) => {
itif(runAllTests)(`with checksum ${checksum} from ${file}`, async () => {
const fileContent = await fs.promises.readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`);
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);

View File

@ -12,7 +12,7 @@ import { Server } from 'tls';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../../src/microservices/app.service';
export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH as string;
export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`);
export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
@ -57,16 +57,10 @@ export const db = {
let _handler: JobItemHandler = () => Promise.resolve();
interface TestAppOptions {
jobs: boolean;
}
let app: INestApplication;
export const testApp = {
create: async (options?: TestAppOptions): Promise<INestApplication> => {
const { jobs } = options || { jobs: false };
create: async (): Promise<INestApplication> => {
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
.overrideModule(InfraModule)
.useModule(InfraTestModule)
@ -77,8 +71,8 @@ export const testApp = {
updateCronJob: jest.fn(),
deleteCronJob: jest.fn(),
validateCronExpression: jest.fn(),
queue: (item: JobItem) => jobs && _handler(item),
queueAll: (items: JobItem[]) => jobs && Promise.all(items.map(_handler)).then(() => Promise.resolve()),
queue: (item: JobItem) => _handler(item),
queueAll: (items: JobItem[]) => Promise.all(items.map(_handler)).then(() => Promise.resolve()),
resume: jest.fn(),
empty: jest.fn(),
setConcurrency: jest.fn(),
@ -113,10 +107,6 @@ export const testApp = {
},
};
export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
export const itif = (condition: boolean) => (condition ? it : it.skip);
const directoryExists = async (dirPath: string) =>
await fs.promises
.access(dirPath)