mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
test(cli): e2e testing (#5101)
* Allow building and installing cli * feat: add format fix * docs: remove cli folder * feat: use immich scoped package * feat: rewrite cli readme * docs: add info on running without building * cleanup * chore: remove import functionality from cli * feat: add logout to cli * docs: add todo for file format from server * docs: add compilation step to cli * fix: success message spacing * feat: can create albums * fix: add check step to cli * fix: typos * feat: pull file formats from server * chore: use crawl service from server * chore: fix lint * docs: add cli documentation * chore: rename ignore pattern * chore: add version number to cli * feat: use sdk * fix: cleanup * feat: album name on windows * chore: remove skipped asset field * feat: add more info to server-info command * chore: cleanup * wip * chore: remove unneeded packages * e2e test can start * git ignore for geocode in cli * add cli e2e to github actions * can do e2e tests in the cli * simplify e2e test * cleanup * set matrix strategy in workflow * run npm ci in server * choose different working directory * check out submodules too * increase test timeout * set node version * cli docker e2e tests * fix cli docker file * run cli e2e in correct folder * set docker context * correct docker build * remove cli from dockerignore * chore: fix docs links * feat: add cli v2 milestone * fix: set correct cli date * remove submodule * chore: add npmignore * chore(cli): push to npm * fix: server e2e * run npm ci in server * remove state from e2e * run npm ci in server * reshuffle docker compose files * use new e2e composes in makefile * increase test timeout to 10 minutes * make github actions run makefile e2e tests * cleanup github test names * assert on server version * chore: split cli e2e tests into one file per command * chore: set cli release working dir * chore: add repo url to npmjs * chore: bump node setup to v4 * chore: normalize the github url * check e2e code in lint * fix lint * test key login flow * feat: allow configurable config dir * fix session service tests * create missing dir * cleanup * bump cli version to 2.0.4 * remove form-data * feat: allow single files as argument * add version option * bump dependencies * fix lint * wip use axios as upload * version bump * cApiTALiZaTiON * don't touch package lock * wip: don't use job queues * don't use make for cli e2e * fix server e2e * chore: remove old gha step * add npm ci to server --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
baed16dab6
commit
4e9b96ff1a
50 changed files with 2486 additions and 149 deletions
|
@ -1,5 +1,5 @@
|
||||||
.vscode/
|
.vscode/
|
||||||
cli/
|
|
||||||
design/
|
design/
|
||||||
docker/
|
docker/
|
||||||
docs/
|
docs/
|
||||||
|
@ -18,3 +18,8 @@ web/node_modules/
|
||||||
web/coverage/
|
web/coverage/
|
||||||
web/.svelte-kit
|
web/.svelte-kit
|
||||||
web/build/
|
web/build/
|
||||||
|
|
||||||
|
cli/node_modules
|
||||||
|
cli/.reverse-geocoding-dump/
|
||||||
|
cli/upload/
|
||||||
|
cli/dist/
|
31
.github/workflows/test.yml
vendored
31
.github/workflows/test.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
||||||
submodules: "recursive"
|
submodules: "recursive"
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
run: make test-server-e2e
|
||||||
|
|
||||||
doc-tests:
|
doc-tests:
|
||||||
name: Docs
|
name: Docs
|
||||||
|
@ -90,9 +90,13 @@ jobs:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run npm install
|
- name: Run npm install in cli
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run npm install in server
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./server
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
@ -109,6 +113,29 @@ jobs:
|
||||||
run: npm run test:cov
|
run: npm run test:cov
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
|
cli-e2e-tests:
|
||||||
|
name: CLI (e2e)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./cli
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: "recursive"
|
||||||
|
|
||||||
|
- name: Run npm install in cli
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run npm install in server
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./server
|
||||||
|
|
||||||
|
- name: Run e2e tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
web-unit-tests:
|
web-unit-tests:
|
||||||
name: Web
|
name: Web
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
4
Makefile
4
Makefile
|
@ -16,8 +16,8 @@ stage:
|
||||||
pull-stage:
|
pull-stage:
|
||||||
docker compose -f ./docker/docker-compose.staging.yml pull
|
docker compose -f ./docker/docker-compose.staging.yml pull
|
||||||
|
|
||||||
test-e2e:
|
test-server-e2e:
|
||||||
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||||
|
|
2
cli/.gitignore
vendored
2
cli/.gitignore
vendored
|
@ -11,3 +11,5 @@ oclif.manifest.json
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
/coverage/
|
/coverage/
|
||||||
|
.reverse-geocoding-dump/
|
||||||
|
upload/
|
|
@ -1,4 +1,6 @@
|
||||||
**/*.spec.js
|
**/*.spec.js
|
||||||
|
test/**
|
||||||
|
upload/**
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.eslintignore
|
.eslintignore
|
||||||
.eslintrc.js
|
.eslintrc.js
|
||||||
|
|
19
cli/Dockerfile
Normal file
19
cli/Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
FROM ghcr.io/immich-app/base-server-dev:20231109 as test
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/server
|
||||||
|
COPY server/package.json server/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY ./server/ .
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app/cli
|
||||||
|
COPY cli/package.json cli/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY ./cli/ .
|
||||||
|
|
||||||
|
FROM ghcr.io/immich-app/base-server-prod:20231109
|
||||||
|
|
||||||
|
VOLUME /usr/src/app/upload
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
1925
cli/package-lock.json
generated
1925
cli/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.0.4",
|
"version": "2.0.5",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
@ -21,6 +21,7 @@
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testcontainers/postgresql": "^10.4.0",
|
||||||
"@types/byte-size": "^8.1.0",
|
"@types/byte-size": "^8.1.0",
|
||||||
"@types/chai": "^4.3.5",
|
"@types/chai": "^4.3.5",
|
||||||
"@types/cli-progress": "^3.11.0",
|
"@types/cli-progress": "^3.11.0",
|
||||||
|
@ -37,6 +38,7 @@
|
||||||
"eslint-plugin-jest": "^27.2.2",
|
"eslint-plugin-jest": "^27.2.2",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"eslint-plugin-unicorn": "^49.0.0",
|
"eslint-plugin-unicorn": "^49.0.0",
|
||||||
|
"immich": "file:../server",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-extended": "^4.0.0",
|
"jest-extended": "^4.0.0",
|
||||||
"jest-message-util": "^29.5.0",
|
"jest-message-util": "^29.5.0",
|
||||||
|
@ -50,13 +52,15 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"prepack": "npm run build",
|
"prepack": "npm run build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit",
|
||||||
|
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"clearMocks": true,
|
"clearMocks": true,
|
||||||
|
@ -71,10 +75,15 @@
|
||||||
"^.+\\.ts$": "ts-jest"
|
"^.+\\.ts$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"<rootDir>/src/**/*.(t|j)s"
|
"<rootDir>/src/**/*.(t|j)s",
|
||||||
|
"!**/open-api/**"
|
||||||
],
|
],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@api(|/.*)$": "<rootDir>/src/api/$1"
|
"^@api(|/.*)$": "<rootDir>/src/api/$1",
|
||||||
|
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||||
|
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||||
|
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||||
|
"^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
|
||||||
},
|
},
|
||||||
"coverageDirectory": "./coverage",
|
"coverageDirectory": "./coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { ImmichApi } from '../api/client';
|
import { ImmichApi } from '../api/client';
|
||||||
import path from 'node:path';
|
|
||||||
import { SessionService } from '../services/session.service';
|
import { SessionService } from '../services/session.service';
|
||||||
import { LoginError } from '../cores/errors/login-error';
|
import { LoginError } from '../cores/errors/login-error';
|
||||||
import { exit } from 'node:process';
|
import { exit } from 'node:process';
|
||||||
import os from 'os';
|
|
||||||
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
|
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
|
||||||
|
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||||
|
|
||||||
export abstract class BaseCommand {
|
export abstract class BaseCommand {
|
||||||
protected sessionService!: SessionService;
|
protected sessionService!: SessionService;
|
||||||
|
@ -12,14 +11,11 @@ export abstract class BaseCommand {
|
||||||
protected user!: UserResponseDto;
|
protected user!: UserResponseDto;
|
||||||
protected serverVersion!: ServerVersionResponseDto;
|
protected serverVersion!: ServerVersionResponseDto;
|
||||||
|
|
||||||
protected configDir;
|
constructor(options: BaseOptionsDto) {
|
||||||
protected authPath;
|
if (!options.config) {
|
||||||
|
throw new Error('Config directory is required');
|
||||||
constructor() {
|
}
|
||||||
const userHomeDir = os.homedir();
|
this.sessionService = new SessionService(options.config);
|
||||||
this.configDir = path.join(userHomeDir, '.config/immich/');
|
|
||||||
this.sessionService = new SessionService(this.configDir);
|
|
||||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async connect(): Promise<void> {
|
public async connect(): Promise<void> {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
|
||||||
import { CrawlService } from '../services';
|
import { CrawlService } from '../services';
|
||||||
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
|
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
|
||||||
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
|
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
|
||||||
|
import fs from 'node:fs';
|
||||||
import cliProgress from 'cli-progress';
|
import cliProgress from 'cli-progress';
|
||||||
import byteSize from 'byte-size';
|
import byteSize from 'byte-size';
|
||||||
import { BaseCommand } from '../cli/base-command';
|
import { BaseCommand } from '../cli/base-command';
|
||||||
|
@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
|
||||||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
|
|
||||||
const deviceId = 'CLI';
|
|
||||||
|
|
||||||
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
|
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
|
||||||
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
|
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
|
||||||
|
|
||||||
|
@ -25,14 +23,26 @@ export default class Upload extends BaseCommand {
|
||||||
crawlOptions.recursive = options.recursive;
|
crawlOptions.recursive = options.recursive;
|
||||||
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
||||||
|
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
for (const pathArgument of paths) {
|
||||||
|
const fileStat = await fs.promises.lstat(pathArgument);
|
||||||
|
|
||||||
|
if (fileStat.isFile()) {
|
||||||
|
files.push(pathArgument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
|
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
|
||||||
|
|
||||||
|
crawledFiles.push(...files);
|
||||||
|
|
||||||
if (crawledFiles.length === 0) {
|
if (crawledFiles.length === 0) {
|
||||||
console.log('No assets found, exiting');
|
console.log('No assets found, exiting');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
|
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
|
||||||
|
|
||||||
const uploadProgress = new cliProgress.SingleBar(
|
const uploadProgress = new cliProgress.SingleBar(
|
||||||
{
|
{
|
||||||
|
|
37
cli/src/constants.ts
Normal file
37
cli/src/constants.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import pkg from '../package.json';
|
||||||
|
|
||||||
|
export interface ICLIVersion {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
patch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CLIVersion implements ICLIVersion {
|
||||||
|
constructor(
|
||||||
|
public readonly major: number,
|
||||||
|
public readonly minor: number,
|
||||||
|
public readonly patch: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `${this.major}.${this.minor}.${this.patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const { major, minor, patch } = this;
|
||||||
|
return { major, minor, patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromString(version: string): CLIVersion {
|
||||||
|
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
|
||||||
|
const matchResult = version.match(regex);
|
||||||
|
if (matchResult) {
|
||||||
|
const [, major, minor, patch] = matchResult.map(Number);
|
||||||
|
return new CLIVersion(major, minor, patch);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid version format: ${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cliVersion = CLIVersion.fromString(pkg.version);
|
3
cli/src/cores/dto/base-options-dto.ts
Normal file
3
cli/src/cores/dto/base-options-dto.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export class BaseOptionsDto {
|
||||||
|
config?: string;
|
||||||
|
}
|
|
@ -1,9 +1,8 @@
|
||||||
export class UploadOptionsDto {
|
export class UploadOptionsDto {
|
||||||
recursive = false;
|
recursive? = false;
|
||||||
exclusionPatterns!: string[];
|
exclusionPatterns?: string[] = [];
|
||||||
dryRun = false;
|
dryRun? = false;
|
||||||
skipHash = false;
|
skipHash? = false;
|
||||||
delete = false;
|
delete? = false;
|
||||||
readOnly = true;
|
album? = false;
|
||||||
album = false;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,8 @@ export class LoginError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
||||||
// assign the error class name in your custom error (as a shortcut)
|
|
||||||
this.name = this.constructor.name;
|
this.name = this.constructor.name;
|
||||||
|
|
||||||
// capturing the stack trace keeps the reference to your error class
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
Error.captureStackTrace(this, this.constructor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,9 +17,8 @@ export class Asset {
|
||||||
fileSize!: number;
|
fileSize!: number;
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
|
|
||||||
constructor(path: string, deviceId: string) {
|
constructor(path: string) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
this.deviceId = deviceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async process() {
|
async process() {
|
||||||
|
@ -45,12 +44,11 @@ export class Asset {
|
||||||
if (!this.deviceAssetId) throw new Error('Device asset id not set');
|
if (!this.deviceAssetId) throw new Error('Device asset id not set');
|
||||||
if (!this.fileCreatedAt) throw new Error('File created at not set');
|
if (!this.fileCreatedAt) throw new Error('File created at not set');
|
||||||
if (!this.fileModifiedAt) throw new Error('File modified at not set');
|
if (!this.fileModifiedAt) throw new Error('File modified at not set');
|
||||||
if (!this.deviceId) throw new Error('Device id not set');
|
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
assetData: this.assetData as any,
|
assetData: this.assetData as any,
|
||||||
deviceAssetId: this.deviceAssetId,
|
deviceAssetId: this.deviceAssetId,
|
||||||
deviceId: this.deviceId,
|
deviceId: 'CLI',
|
||||||
fileCreatedAt: this.fileCreatedAt,
|
fileCreatedAt: this.fileCreatedAt,
|
||||||
fileModifiedAt: this.fileModifiedAt,
|
fileModifiedAt: this.fileModifiedAt,
|
||||||
isFavorite: String(false),
|
isFavorite: String(false),
|
||||||
|
|
|
@ -1,13 +1,23 @@
|
||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
|
|
||||||
import { program, Option } from 'commander';
|
import { Option, Command } from 'commander';
|
||||||
import Upload from './commands/upload';
|
import Upload from './commands/upload';
|
||||||
import ServerInfo from './commands/server-info';
|
import ServerInfo from './commands/server-info';
|
||||||
import LoginKey from './commands/login/key';
|
import LoginKey from './commands/login/key';
|
||||||
import Logout from './commands/logout';
|
import Logout from './commands/logout';
|
||||||
import { version } from '../package.json';
|
import { version } from '../package.json';
|
||||||
|
|
||||||
program.name('immich').description('Immich command line interface').version(version);
|
import path from 'node:path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const userHomeDir = os.homedir();
|
||||||
|
const configDir = path.join(userHomeDir, '.config/immich/');
|
||||||
|
|
||||||
|
const program = new Command()
|
||||||
|
.name('immich')
|
||||||
|
.version(version)
|
||||||
|
.description('Command line interface for Immich')
|
||||||
|
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('upload')
|
.command('upload')
|
||||||
|
@ -30,14 +40,14 @@ program
|
||||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||||
.action(async (paths, options) => {
|
.action(async (paths, options) => {
|
||||||
options.exclusionPatterns = options.ignore;
|
options.exclusionPatterns = options.ignore;
|
||||||
await new Upload().run(paths, options);
|
await new Upload(program.opts()).run(paths, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('server-info')
|
.command('server-info')
|
||||||
.description('Display server information')
|
.description('Display server information')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
await new ServerInfo().run();
|
await new ServerInfo(program.opts()).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
|
@ -46,14 +56,14 @@ program
|
||||||
.argument('[instanceUrl]')
|
.argument('[instanceUrl]')
|
||||||
.argument('[apiKey]')
|
.argument('[apiKey]')
|
||||||
.action(async (paths, options) => {
|
.action(async (paths, options) => {
|
||||||
await new LoginKey().run(paths, options);
|
await new LoginKey(program.opts()).run(paths, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('logout')
|
.command('logout')
|
||||||
.description('Remove stored credentials')
|
.description('Remove stored credentials')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
await new Logout().run();
|
await new Logout(program.opts()).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
import mockfs from 'mock-fs';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import yaml from 'yaml';
|
import yaml from 'yaml';
|
||||||
import { LoginError } from '../cores/errors/login-error';
|
import { LoginError } from '../cores/errors/login-error';
|
||||||
|
import {
|
||||||
|
TEST_AUTH_FILE,
|
||||||
|
TEST_CONFIG_DIR,
|
||||||
|
TEST_IMMICH_API_KEY,
|
||||||
|
TEST_IMMICH_INSTANCE_URL,
|
||||||
|
createTestAuthFile,
|
||||||
|
deleteAuthFile,
|
||||||
|
readTestAuthFile,
|
||||||
|
spyOnConsole,
|
||||||
|
} from '../../test/cli-test-utils';
|
||||||
|
|
||||||
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
|
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
|
||||||
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
|
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
|
||||||
|
@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
|
||||||
|
|
||||||
describe('SessionService', () => {
|
describe('SessionService', () => {
|
||||||
let sessionService: SessionService;
|
let sessionService: SessionService;
|
||||||
|
let consoleSpy: jest.SpyInstance;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Write a dummy output before mock-fs to prevent some annoying errors
|
consoleSpy = spyOnConsole();
|
||||||
console.log();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const configDir = '/config';
|
deleteAuthFile();
|
||||||
sessionService = new SessionService(configDir);
|
sessionService = new SessionService(TEST_CONFIG_DIR);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
deleteAuthFile();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should connect to immich', async () => {
|
it('should connect to immich', async () => {
|
||||||
mockfs({
|
await createTestAuthFile(
|
||||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
JSON.stringify({
|
||||||
});
|
apiKey: TEST_IMMICH_API_KEY,
|
||||||
|
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await sessionService.connect();
|
await sessionService.connect();
|
||||||
expect(mockPingServer).toHaveBeenCalledTimes(1);
|
expect(mockPingServer).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error if no auth file exists', async () => {
|
it('should error if no auth file exists', async () => {
|
||||||
mockfs();
|
|
||||||
await sessionService.connect().catch((error) => {
|
await sessionService.connect().catch((error) => {
|
||||||
expect(error.message).toEqual('No auth file exist. Please login first');
|
expect(error.message).toEqual('No auth file exist. Please login first');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error if auth file is missing instance URl', async () => {
|
it('should error if auth file is missing instance URl', async () => {
|
||||||
mockfs({
|
await createTestAuthFile(
|
||||||
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
|
JSON.stringify({
|
||||||
});
|
apiKey: TEST_IMMICH_API_KEY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await sessionService.connect().catch((error) => {
|
await sessionService.connect().catch((error) => {
|
||||||
expect(error).toBeInstanceOf(LoginError);
|
expect(error).toBeInstanceOf(LoginError);
|
||||||
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
|
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error if auth file is missing api key', async () => {
|
it('should error if auth file is missing api key', async () => {
|
||||||
mockfs({
|
await createTestAuthFile(
|
||||||
'/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
|
JSON.stringify({
|
||||||
});
|
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||||
await sessionService.connect().catch((error) => {
|
}),
|
||||||
expect(error).toBeInstanceOf(LoginError);
|
);
|
||||||
expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
|
|
||||||
});
|
await expect(sessionService.connect()).rejects.toThrow(
|
||||||
|
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('should create auth file when logged in', async () => {
|
it('should create auth file when logged in', async () => {
|
||||||
mockfs();
|
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
|
||||||
|
|
||||||
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
const data: string = await readTestAuthFile();
|
||||||
|
|
||||||
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
|
|
||||||
const authConfig = yaml.parse(data);
|
const authConfig = yaml.parse(data);
|
||||||
expect(authConfig.instanceUrl).toBe('https://test/api');
|
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
|
||||||
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete auth file when logging out', async () => {
|
it('should delete auth file when logging out', async () => {
|
||||||
mockfs({
|
await createTestAuthFile(
|
||||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
JSON.stringify({
|
||||||
});
|
apiKey: TEST_IMMICH_API_KEY,
|
||||||
|
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await sessionService.logout();
|
await sessionService.logout();
|
||||||
|
|
||||||
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
|
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
|
||||||
expect(error.message).toContain('ENOENT');
|
expect(error.message).toContain('ENOENT');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
|
||||||
mockfs.restore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,16 +5,20 @@ import { ImmichApi } from '../api/client';
|
||||||
import { LoginError } from '../cores/errors/login-error';
|
import { LoginError } from '../cores/errors/login-error';
|
||||||
|
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
readonly configDir: string;
|
readonly configDir!: string;
|
||||||
readonly authPath!: string;
|
readonly authPath!: string;
|
||||||
private api!: ImmichApi;
|
private api!: ImmichApi;
|
||||||
|
|
||||||
constructor(configDir: string) {
|
constructor(configDir: string) {
|
||||||
this.configDir = configDir;
|
this.configDir = configDir;
|
||||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
this.authPath = path.join(configDir, '/auth.yml');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async connect(): Promise<ImmichApi> {
|
public async connect(): Promise<ImmichApi> {
|
||||||
|
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||||
|
let apiKey = process.env.IMMICH_API_KEY;
|
||||||
|
|
||||||
|
if (!instanceUrl || !apiKey) {
|
||||||
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
|
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
throw new LoginError('No auth file exist. Please login first');
|
throw new LoginError('No auth file exist. Please login first');
|
||||||
|
@ -23,15 +27,17 @@ export class SessionService {
|
||||||
|
|
||||||
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
|
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
|
||||||
const parsedConfig = yaml.parse(data);
|
const parsedConfig = yaml.parse(data);
|
||||||
const instanceUrl: string = parsedConfig.instanceUrl;
|
|
||||||
const apiKey: string = parsedConfig.apiKey;
|
instanceUrl = parsedConfig.instanceUrl;
|
||||||
|
apiKey = parsedConfig.apiKey;
|
||||||
|
|
||||||
if (!instanceUrl) {
|
if (!instanceUrl) {
|
||||||
throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
|
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new LoginError('API key missing in auth config file ' + this.authPath);
|
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.api = new ImmichApi(instanceUrl, apiKey);
|
this.api = new ImmichApi(instanceUrl, apiKey);
|
||||||
|
@ -59,10 +65,6 @@ export class SessionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(this.configDir)) {
|
|
||||||
console.error('waah');
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
||||||
|
|
||||||
console.log('Wrote auth info to ' + this.authPath);
|
console.log('Wrote auth info to ' + this.authPath);
|
||||||
|
@ -82,7 +84,7 @@ export class SessionService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pingResponse.res !== 'pong') {
|
if (pingResponse.res !== 'pong') {
|
||||||
throw new Error('Unexpected ping reply');
|
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
38
cli/test/cli-test-utils.ts
Normal file
38
cli/test/cli-test-utils.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const TEST_CONFIG_DIR = '/tmp/immich/';
|
||||||
|
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
|
||||||
|
export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
|
||||||
|
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
|
||||||
|
|
||||||
|
export const CLI_BASE_OPTIONS: BaseOptionsDto = { config: TEST_CONFIG_DIR };
|
||||||
|
|
||||||
|
export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
|
||||||
|
export const createTestAuthFile = async (contents: string) => {
|
||||||
|
if (!fs.existsSync(TEST_CONFIG_DIR)) {
|
||||||
|
// Create config folder if it doesn't exist
|
||||||
|
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
|
||||||
|
if (!created) {
|
||||||
|
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(TEST_AUTH_FILE, contents);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readTestAuthFile = async (): Promise<string> => {
|
||||||
|
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAuthFile = () => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(TEST_AUTH_FILE);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
24
cli/test/e2e/jest-e2e.json
Normal file
24
cli/test/e2e/jest-e2e.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"modulePaths": ["<rootDir>"],
|
||||||
|
"rootDir": "../..",
|
||||||
|
"globalSetup": "<rootDir>/test/e2e/setup.ts",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"testTimeout": 6000000,
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"<rootDir>/src/**/*.(t|j)s",
|
||||||
|
"!<rootDir>/src/**/*.spec.(t|s)s",
|
||||||
|
"!<rootDir>/src/infra/migrations/**"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "./coverage",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||||
|
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||||
|
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||||
|
"^@app/domain(|/.*)$": "<rootDir>/../server/src/domain/$1"
|
||||||
|
}
|
||||||
|
}
|
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { api } from '@test/api';
|
||||||
|
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||||
|
import { LoginResponseDto } from 'src/api/open-api';
|
||||||
|
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||||
|
import LoginKey from 'src/commands/login/key';
|
||||||
|
import { LoginError } from 'src/cores/errors/login-error';
|
||||||
|
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||||
|
|
||||||
|
describe(`login-key (e2e)`, () => {
|
||||||
|
let server: any;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let apiKey: APIKeyCreateResponseDto;
|
||||||
|
let instanceUrl: string;
|
||||||
|
spyOnConsole();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
if (!process.env.IMMICH_INSTANCE_URL) {
|
||||||
|
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
|
||||||
|
} else {
|
||||||
|
instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.teardown();
|
||||||
|
await restoreTempFolder();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testApp.reset();
|
||||||
|
await restoreTempFolder();
|
||||||
|
await api.authApi.adminSignUp(server);
|
||||||
|
admin = await api.authApi.adminLogin(server);
|
||||||
|
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||||
|
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should error when providing an invalid API key', async () => {
|
||||||
|
await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
|
||||||
|
new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log in when providing the correct API key', async () => {
|
||||||
|
await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, apiKey.secret);
|
||||||
|
});
|
||||||
|
});
|
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { api } from '@test/api';
|
||||||
|
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||||
|
import { LoginResponseDto } from 'src/api/open-api';
|
||||||
|
import ServerInfo from 'src/commands/server-info';
|
||||||
|
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||||
|
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||||
|
|
||||||
|
describe(`server-info (e2e)`, () => {
|
||||||
|
let server: any;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let apiKey: APIKeyCreateResponseDto;
|
||||||
|
const consoleSpy = spyOnConsole();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.teardown();
|
||||||
|
await restoreTempFolder();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testApp.reset();
|
||||||
|
await restoreTempFolder();
|
||||||
|
await api.authApi.adminSignUp(server);
|
||||||
|
admin = await api.authApi.adminLogin(server);
|
||||||
|
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||||
|
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show server version', async () => {
|
||||||
|
await new ServerInfo(CLI_BASE_OPTIONS).run();
|
||||||
|
|
||||||
|
expect(consoleSpy.mock.calls).toEqual([
|
||||||
|
[expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))],
|
||||||
|
[expect.stringMatching('Supported image types: .*')],
|
||||||
|
[expect.stringMatching('Supported video types: .*')],
|
||||||
|
['Images: 0, Videos: 0, Total: 0'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
43
cli/test/e2e/setup.ts
Normal file
43
cli/test/e2e/setup.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import path from 'path';
|
||||||
|
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||||
|
import { access } from 'fs/promises';
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
let IMMICH_TEST_ASSET_PATH: string = '';
|
||||||
|
|
||||||
|
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
||||||
|
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`);
|
||||||
|
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
||||||
|
} else {
|
||||||
|
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryExists = async (dirPath: string) =>
|
||||||
|
await access(dirPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('tensorchord/pgvecto-rs:pg14-v0.1.11')
|
||||||
|
.withExposedPorts(5432)
|
||||||
|
.withDatabase('immich')
|
||||||
|
.withUsername('postgres')
|
||||||
|
.withPassword('postgres')
|
||||||
|
.withReuse()
|
||||||
|
.start();
|
||||||
|
|
||||||
|
process.env.DB_URL = pg.getConnectionUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
process.env.IMMICH_TEST_ENV = 'true';
|
||||||
|
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/test/e2e/immich-e2e-config.json`);
|
||||||
|
process.env.TZ = 'Z';
|
||||||
|
};
|
49
cli/test/e2e/upload.e2e-spec.ts
Normal file
49
cli/test/e2e/upload.e2e-spec.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { api } from '@test/api';
|
||||||
|
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||||
|
import { LoginResponseDto } from 'src/api/open-api';
|
||||||
|
import Upload from 'src/commands/upload';
|
||||||
|
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||||
|
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||||
|
|
||||||
|
describe(`upload (e2e)`, () => {
|
||||||
|
let server: any;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let apiKey: APIKeyCreateResponseDto;
|
||||||
|
spyOnConsole();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.teardown();
|
||||||
|
await restoreTempFolder();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testApp.reset();
|
||||||
|
await restoreTempFolder();
|
||||||
|
await api.authApi.adminSignUp(server);
|
||||||
|
admin = await api.authApi.adminLogin(server);
|
||||||
|
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||||
|
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upload a folder recursively', async () => {
|
||||||
|
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||||
|
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
|
||||||
|
expect(assets.length).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create album from folder name', async () => {
|
||||||
|
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||||
|
recursive: true,
|
||||||
|
album: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||||
|
expect(albums.length).toEqual(1);
|
||||||
|
const natureAlbum = albums[0];
|
||||||
|
expect(natureAlbum.albumName).toEqual('nature');
|
||||||
|
});
|
||||||
|
});
|
3
cli/test/global-setup.js
Normal file
3
cli/test/global-setup.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = async () => {
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
};
|
|
@ -8,17 +8,24 @@
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2021",
|
||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"rootDirs": ["src", "../server/src"],
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@test": ["test"],
|
"@test": ["../server/test"],
|
||||||
"@test/*": ["test/*"]
|
"@test/*": ["../server/test/*"],
|
||||||
|
"@app/immich": ["../server/src/immich"],
|
||||||
|
"@app/immich/*": ["../server/src/immich/*"],
|
||||||
|
"@app/infra": ["../server/src/infra"],
|
||||||
|
"@app/infra/*": ["../server/src/infra/*"],
|
||||||
|
"@app/domain": ["../server/src/domain"],
|
||||||
|
"@app/domain/*": ["../server/src/domain/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["dist", "node_modules", "upload"]
|
"exclude": ["dist", "node_modules", "upload"]
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { RedisOptions } from 'ioredis';
|
||||||
|
|
||||||
function parseRedisConfig(): RedisOptions {
|
function parseRedisConfig(): RedisOptions {
|
||||||
if (process.env.IMMICH_TEST_ENV == 'true') {
|
if (process.env.IMMICH_TEST_ENV == 'true') {
|
||||||
|
// Currently running e2e tests, do not use redis
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -101,6 +101,7 @@ const imports = [
|
||||||
const moduleExports = [...providers];
|
const moduleExports = [...providers];
|
||||||
|
|
||||||
if (process.env.IMMICH_TEST_ENV !== 'true') {
|
if (process.env.IMMICH_TEST_ENV !== 'true') {
|
||||||
|
// Currently not running e2e tests, set up redis and bull queues
|
||||||
imports.push(BullModule.forRoot(bullConfig));
|
imports.push(BullModule.forRoot(bullConfig));
|
||||||
imports.push(BullModule.registerQueue(...bullQueues));
|
imports.push(BullModule.registerQueue(...bullQueues));
|
||||||
moduleExports.push(BullModule);
|
moduleExports.push(BullModule);
|
||||||
|
|
|
@ -20,4 +20,9 @@ export const albumApi = {
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
return res.body as AlbumResponseDto;
|
return res.body as AlbumResponseDto;
|
||||||
},
|
},
|
||||||
|
getAllAlbums: async (server: any, accessToken: string) => {
|
||||||
|
const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
return res.body as AlbumResponseDto[];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
16
server/test/api/api-key-api.ts
Normal file
16
server/test/api/api-key-api.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||||
|
import { apiKeyCreateStub } from '@test';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
export const apiKeyApi = {
|
||||||
|
createApiKey: async (server: any, accessToken: string) => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.post('/api-key')
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(apiKeyCreateStub);
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
return body as APIKeyCreateResponseDto;
|
||||||
|
},
|
||||||
|
};
|
|
@ -1,5 +1,6 @@
|
||||||
import { activityApi } from './activity-api';
|
import { activityApi } from './activity-api';
|
||||||
import { albumApi } from './album-api';
|
import { albumApi } from './album-api';
|
||||||
|
import { apiKeyApi } from './api-key-api';
|
||||||
import { assetApi } from './asset-api';
|
import { assetApi } from './asset-api';
|
||||||
import { authApi } from './auth-api';
|
import { authApi } from './auth-api';
|
||||||
import { libraryApi } from './library-api';
|
import { libraryApi } from './library-api';
|
||||||
|
@ -10,6 +11,7 @@ import { userApi } from './user-api';
|
||||||
export const api = {
|
export const api = {
|
||||||
activityApi,
|
activityApi,
|
||||||
authApi,
|
authApi,
|
||||||
|
apiKeyApi,
|
||||||
assetApi,
|
assetApi,
|
||||||
libraryApi,
|
libraryApi,
|
||||||
sharedLinkApi,
|
sharedLinkApi,
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
version: "3.8"
|
version: '3.8'
|
||||||
|
|
||||||
name: "immich-test-e2e"
|
name: 'immich-test-e2e'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
immich-server:
|
immich-server:
|
||||||
image: immich-server-dev:latest
|
image: immich-server-dev:latest
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
target: dev
|
target: dev
|
||||||
entrypoint: [ "/usr/local/bin/npm", "run" ]
|
entrypoint: ['/usr/local/bin/npm', 'run']
|
||||||
command: test:e2e
|
command: test:e2e
|
||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- DB_HOSTNAME=database
|
- DB_HOSTNAME=database
|
|
@ -15,7 +15,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
||||||
let nonOwner: LoginResponseDto;
|
let nonOwner: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
await api.authApi.adminSignUp(server);
|
await api.authApi.adminSignUp(server);
|
||||||
admin = await api.authApi.adminLogin(server);
|
admin = await api.authApi.adminLogin(server);
|
||||||
|
|
|
@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
||||||
let user2Albums: AlbumResponseDto[];
|
let user2Albums: AlbumResponseDto[];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -63,7 +63,8 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server, app] = await testApp.create();
|
app = await testApp.create();
|
||||||
|
server = app.getHttpServer();
|
||||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||||
|
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
|
|
|
@ -39,8 +39,7 @@ describe(`${AuthController.name} (e2e)`, () => {
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testApp.reset();
|
server = (await testApp.create()).getHttpServer();
|
||||||
[server] = await testApp.create();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -90,10 +90,7 @@ describe(`Supported file formats (e2e)`, () => {
|
||||||
iso: 20,
|
iso: 20,
|
||||||
focalLength: 3.99,
|
focalLength: 3.99,
|
||||||
fNumber: 1.8,
|
fNumber: 1.8,
|
||||||
state: 'Douglas County, Nebraska',
|
|
||||||
timeZone: 'America/Chicago',
|
timeZone: 'America/Chicago',
|
||||||
city: 'Ralston',
|
|
||||||
country: 'United States of America',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -168,7 +165,7 @@ describe(`Supported file formats (e2e)`, () => {
|
||||||
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
|
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create({ jobs: true });
|
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
11
server/test/e2e/immich-e2e-config.json
Normal file
11
server/test/e2e/immich-e2e-config.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"reverseGeocoding": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"machineLearning": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create({ jobs: true });
|
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
|
||||||
let server: any;
|
let server: any;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -12,7 +12,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
|
||||||
let user3: LoginResponseDto;
|
let user3: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
await api.authApi.adminSignUp(server);
|
await api.authApi.adminSignUp(server);
|
||||||
|
|
|
@ -17,7 +17,8 @@ describe(`${PersonController.name}`, () => {
|
||||||
let hiddenPerson: PersonEntity;
|
let hiddenPerson: PersonEntity;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server, app] = await testApp.create();
|
app = await testApp.create();
|
||||||
|
server = app.getHttpServer();
|
||||||
personRepository = app.get<IPersonRepository>(IPersonRepository);
|
personRepository = app.get<IPersonRepository>(IPersonRepository);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,8 @@ describe(`${SearchController.name}`, () => {
|
||||||
let asset1: AssetResponseDto;
|
let asset1: AssetResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server, app] = await testApp.create();
|
app = await testApp.create();
|
||||||
|
server = app.getHttpServer();
|
||||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||||
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
|
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
||||||
let nonAdmin: LoginResponseDto;
|
let nonAdmin: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
await api.authApi.adminSignUp(server);
|
await api.authApi.adminSignUp(server);
|
||||||
|
@ -74,10 +74,10 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
clipEncode: false,
|
clipEncode: false,
|
||||||
configFile: false,
|
configFile: true,
|
||||||
facialRecognition: false,
|
facialRecognition: false,
|
||||||
map: true,
|
map: true,
|
||||||
reverseGeocoding: true,
|
reverseGeocoding: false,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
oauthAutoLaunch: false,
|
oauthAutoLaunch: false,
|
||||||
passwordLogin: true,
|
passwordLogin: true,
|
||||||
|
|
|
@ -8,7 +8,7 @@ export default async () => {
|
||||||
if (!allTests) {
|
if (!allTests) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`\n\n
|
`\n\n
|
||||||
*** Not running all e2e tests. Run 'make test-e2e' to run all tests inside Docker (recommended)\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`,
|
*** or set 'IMMICH_RUN_ALL_TESTS=true' to run all tests (requires dependencies to be installed)\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@ export default async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
|
|
||||||
process.env.IMMICH_TEST_ENV = 'true';
|
process.env.IMMICH_TEST_ENV = 'true';
|
||||||
|
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/immich-e2e-config.json`);
|
||||||
process.env.TZ = 'Z';
|
process.env.TZ = 'Z';
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,7 +33,8 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
||||||
let app: INestApplication<any>;
|
let app: INestApplication<any>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server, app] = await testApp.create();
|
app = await testApp.create();
|
||||||
|
server = app.getHttpServer();
|
||||||
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||||
|
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
|
|
|
@ -11,7 +11,7 @@ describe(`${SystemConfigController.name} (e2e)`, () => {
|
||||||
let nonAdmin: LoginResponseDto;
|
let nonAdmin: LoginResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server] = await testApp.create();
|
server = (await testApp.create()).getHttpServer();
|
||||||
|
|
||||||
await testApp.reset();
|
await testApp.reset();
|
||||||
await api.authApi.adminSignUp(server);
|
await api.authApi.adminSignUp(server);
|
||||||
|
|
|
@ -18,7 +18,8 @@ describe(`${UserController.name}`, () => {
|
||||||
let userRepository: Repository<UserEntity>;
|
let userRepository: Repository<UserEntity>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
[server, app] = await testApp.create();
|
app = await testApp.create();
|
||||||
|
server = app.getHttpServer();
|
||||||
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
|
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
4
server/test/fixtures/api-key.stub.ts
vendored
4
server/test/fixtures/api-key.stub.ts
vendored
|
@ -11,3 +11,7 @@ export const keyStub = {
|
||||||
user: userStub.admin,
|
user: userStub.admin,
|
||||||
} as APIKeyEntity),
|
} as APIKeyEntity),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiKeyCreateStub = {
|
||||||
|
name: 'API Key',
|
||||||
|
};
|
||||||
|
|
|
@ -4,10 +4,12 @@ import { dataSource, databaseChecks } from '@app/infra';
|
||||||
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
|
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { Server } from 'tls';
|
||||||
import { EntityTarget, ObjectLiteral } from 'typeorm';
|
import { EntityTarget, ObjectLiteral } from 'typeorm';
|
||||||
import { AppService } from '../src/microservices/app.service';
|
import { AppService } from '../src/microservices/app.service';
|
||||||
|
|
||||||
|
@ -61,7 +63,7 @@ interface TestAppOptions {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
export const testApp = {
|
export const testApp = {
|
||||||
create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => {
|
create: async (options?: TestAppOptions): Promise<INestApplication> => {
|
||||||
const { jobs } = options || { jobs: false };
|
const { jobs } = options || { jobs: false };
|
||||||
|
|
||||||
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
|
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
|
||||||
|
@ -84,20 +86,27 @@ export const testApp = {
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = await moduleFixture.createNestApplication().init();
|
app = await moduleFixture.createNestApplication().init();
|
||||||
|
await app.listen(0);
|
||||||
|
|
||||||
if (jobs) {
|
if (jobs) {
|
||||||
await app.get(AppService).init();
|
await app.get(AppService).init();
|
||||||
}
|
}
|
||||||
|
|
||||||
return [app.getHttpServer(), app];
|
const port = app.getHttpServer().address().port;
|
||||||
|
const protocol = app instanceof Server ? 'https' : 'http';
|
||||||
|
process.env.IMMICH_INSTANCE_URL = protocol + '://127.0.0.1:' + port;
|
||||||
|
|
||||||
|
return app;
|
||||||
},
|
},
|
||||||
reset: async (options?: ResetOptions) => {
|
reset: async (options?: ResetOptions) => {
|
||||||
await db.reset(options);
|
await db.reset(options);
|
||||||
},
|
},
|
||||||
teardown: async () => {
|
teardown: async () => {
|
||||||
|
if (app) {
|
||||||
await app.get(AppService).teardown();
|
await app.get(AppService).teardown();
|
||||||
await db.disconnect();
|
|
||||||
await app.close();
|
await app.close();
|
||||||
|
}
|
||||||
|
await db.disconnect();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue