mirror of https://github.com/immich-app/immich.git
test(server): full backend end-to-end testing with microservices (#4225)
* feat: asset e2e with job option * feat: checkout test assets * feat: library e2e tests * fix: use node 21 in e2e * fix: tests * fix: use normalized external path * feat: more external path tests * chore: use parametrized tests * chore: remove unused test code * chore: refactor test asset path * feat: centralize test app creation * fix: correct error message for missing assets * feat: test file formats * fix: don't compare checksum * feat: build libvips * fix: install meson * fix: use immich test asset repo * feat: test nikon raw files * fix: set Z timezone * feat: test offline library files * feat: richer metadata tests * feat: e2e tests in docker * feat: e2e test with arm64 docker * fix: manual docker compose run * fix: remove metadata processor import * fix: run e2e tests in test.yml * fix: checkout e2e assets * fix: typo * fix: checkout files in app directory * fix: increase e2e memory * fix: rm submodules * fix: revert action name * test: mark file offline when external path changes * feat: rename env var to TEST_ENV * docs: new test procedures * feat: can run docker e2e tests manually if needed * chore: use new node 20.8 for e2e * chore: bump exiftool-vendored * feat: simplify test launching * fix: rename env vars to use immich_ prefix * feat: asset folder is submodule * chore: cleanup after 20.8 upgrade * fix: don't log postgres in e2e * fix: better warning about not running all tests --------- Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>pull/4378/head
parent
2f9d0a2404
commit
8d5bf93360
@ -1,3 +1,6 @@
|
||||
[submodule "mobile/.isar"]
|
||||
path = mobile/.isar
|
||||
url = https://github.com/isar/isar
|
||||
[submodule "server/test/assets"]
|
||||
path = server/test/assets
|
||||
url = https://github.com/immich-app/test-assets
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
# Database
|
||||
DB_HOSTNAME=immich-database-test
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE_NAME=e2e_test
|
||||
|
||||
# Redis
|
||||
REDIS_HOSTNAME=immich-redis-test
|
||||
|
||||
# Upload File Config
|
||||
UPLOAD_LOCATION=./upload
|
||||
|
||||
# WEB
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
|
||||
TYPESENSE_ENABLED=false
|
||||
@ -0,0 +1,17 @@
|
||||
# Testing
|
||||
|
||||
## Server
|
||||
|
||||
### Unit tests
|
||||
|
||||
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.
|
||||
|
||||
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 perfom 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 test:e2e`.
|
||||
@ -0,0 +1,206 @@
|
||||
import { LoginResponseDto } from '@app/domain';
|
||||
import { AssetType, LibraryType } from '@app/infra/entities';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { api } from '@test/api';
|
||||
import { IMMICH_TEST_ASSET_PATH, createTestApp, db, runAllTests } from '@test/test-utils';
|
||||
|
||||
describe(`Supported file formats (e2e)`, () => {
|
||||
let app: INestApplication;
|
||||
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: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
format: 'heic',
|
||||
path: 'heic',
|
||||
runTest: runAllTests,
|
||||
expectedAsset: {
|
||||
type: AssetType.IMAGE,
|
||||
originalFileName: 'IMG_2682',
|
||||
resized: true,
|
||||
fileCreatedAt: '2019-03-21T16:04:22.348Z',
|
||||
},
|
||||
expectedExif: {
|
||||
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
|
||||
exifImageWidth: 4032,
|
||||
exifImageHeight: 3024,
|
||||
latitude: 41.2203,
|
||||
longitude: -96.071625,
|
||||
make: 'Apple',
|
||||
model: 'iPhone 7',
|
||||
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
|
||||
fileSizeInByte: 880703,
|
||||
exposureTime: '1/887',
|
||||
iso: 20,
|
||||
focalLength: 3.99,
|
||||
fNumber: 1.8,
|
||||
state: 'Douglas County, Nebraska',
|
||||
timeZone: 'America/Chicago',
|
||||
city: 'Ralston',
|
||||
country: 'United States of America',
|
||||
},
|
||||
},
|
||||
{
|
||||
format: 'png',
|
||||
path: 'png',
|
||||
runTest: true,
|
||||
expectedAsset: {
|
||||
type: AssetType.IMAGE,
|
||||
originalFileName: 'density_plot',
|
||||
resized: true,
|
||||
},
|
||||
expectedExif: {
|
||||
exifImageWidth: 800,
|
||||
exifImageHeight: 800,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
fileSizeInByte: 25408,
|
||||
},
|
||||
},
|
||||
{
|
||||
format: 'nef (Nikon D80)',
|
||||
path: 'raw/Nikon/D80',
|
||||
runTest: true,
|
||||
expectedAsset: {
|
||||
type: AssetType.IMAGE,
|
||||
originalFileName: 'glarus',
|
||||
resized: true,
|
||||
fileCreatedAt: '2010-07-20T17:27:12.000Z',
|
||||
},
|
||||
expectedExif: {
|
||||
make: 'NIKON CORPORATION',
|
||||
model: 'NIKON D80',
|
||||
exposureTime: '1/200',
|
||||
fNumber: 10,
|
||||
focalLength: 18,
|
||||
iso: 100,
|
||||
fileSizeInByte: 9057784,
|
||||
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
orientation: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
format: 'nef (Nikon D700)',
|
||||
path: 'raw/Nikon/D700',
|
||||
runTest: true,
|
||||
expectedAsset: {
|
||||
type: AssetType.IMAGE,
|
||||
originalFileName: 'philadelphia',
|
||||
resized: true,
|
||||
fileCreatedAt: '2016-09-22T22:10:29.060Z',
|
||||
},
|
||||
expectedExif: {
|
||||
make: 'NIKON CORPORATION',
|
||||
model: 'NIKON D700',
|
||||
exposureTime: '1/400',
|
||||
fNumber: 11,
|
||||
focalLength: 85,
|
||||
iso: 200,
|
||||
fileSizeInByte: 15856335,
|
||||
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
orientation: '1',
|
||||
timeZone: 'UTC-5',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Only run tests with runTest = true
|
||||
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp(true);
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.disconnect();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
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}`],
|
||||
});
|
||||
|
||||
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id, {});
|
||||
|
||||
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
|
||||
|
||||
expect(assets).toEqual([
|
||||
expect.objectContaining({
|
||||
...testedFormat.expectedAsset,
|
||||
exifInfo: expect.objectContaining(testedFormat.expectedExif),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,55 @@
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import { GenericContainer } from 'testcontainers';
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.TYPESENSE_ENABLED = 'false';
|
||||
const allTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
|
||||
|
||||
if (!allTests) {
|
||||
console.warn(
|
||||
`\n\n
|
||||
*** Not running all 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 = '';
|
||||
|
||||
const pg = await new PostgreSqlContainer('postgres')
|
||||
.withExposedPorts(5432)
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
.withPassword('postgres')
|
||||
.withReuse()
|
||||
.start();
|
||||
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
||||
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../assets/`);
|
||||
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
||||
} else {
|
||||
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
||||
}
|
||||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
const directoryExists = async (dirPath: string) =>
|
||||
await fs.promises
|
||||
.access(dirPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const redis = await new GenericContainer('redis').withExposedPorts(6379).withReuse().start();
|
||||
if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
|
||||
throw new Error(
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
|
||||
);
|
||||
}
|
||||
|
||||
process.env.REDIS_PORT = String(redis.getMappedPort(6379));
|
||||
process.env.REDIS_HOSTNAME = redis.getHost();
|
||||
if (process.env.DB_HOSTNAME === undefined) {
|
||||
// DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
|
||||
const pg = await new PostgreSqlContainer('postgres')
|
||||
.withExposedPorts(5432)
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
.withPassword('postgres')
|
||||
.withReuse()
|
||||
.start();
|
||||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.TYPESENSE_ENABLED = 'false';
|
||||
process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
|
||||
process.env.IMMICH_TEST_ENV = 'true';
|
||||
process.env.TZ = 'Z';
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue