mirror of
https://github.com/immich-app/immich.git
synced 2026-02-02 10:14:50 -08:00
Compare commits
1 Commits
v1.95.0
...
object-sto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a2725c3e |
6
.github/workflows/static_analysis.yml
vendored
6
.github/workflows/static_analysis.yml
vendored
@@ -32,11 +32,7 @@ jobs:
|
||||
- name: Run dart analyze
|
||||
run: dart analyze --fatal-infos
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Run dart format
|
||||
run: dart format lib/ --set-exit-if-changed
|
||||
working-directory: ./mobile
|
||||
|
||||
|
||||
# Enable after riverpod generator migration is completed
|
||||
# - name: Run dart custom lint
|
||||
# run: dart run custom_lint
|
||||
|
||||
68
.github/workflows/test.yml
vendored
68
.github/workflows/test.yml
vendored
@@ -135,6 +135,38 @@ jobs:
|
||||
run: npm run test:cov
|
||||
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: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run npm install (cli)
|
||||
run: npm ci
|
||||
|
||||
- name: Run npm install (server)
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./server
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
|
||||
web-unit-tests:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
@@ -173,8 +205,8 @@ jobs:
|
||||
run: npm run test:cov
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
e2e-tests:
|
||||
name: End-to-End Tests
|
||||
web-e2e-tests:
|
||||
name: Web (e2e)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -183,22 +215,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run setup cli
|
||||
run: npm ci && npm run build
|
||||
working-directory: ./cli
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -206,12 +227,10 @@ jobs:
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
run: docker compose -f docker/docker-compose.e2e.yml build
|
||||
working-directory: ./
|
||||
|
||||
- name: Run e2e tests (api & cli)
|
||||
run: npm run test
|
||||
|
||||
- name: Run e2e tests (web)
|
||||
- name: Run e2e tests
|
||||
run: npx playwright test
|
||||
|
||||
mobile-unit-tests:
|
||||
@@ -258,19 +277,6 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest app --cov=app --cov-report term-missing
|
||||
|
||||
shellcheck:
|
||||
name: ShellCheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
with:
|
||||
ignore_paths: >-
|
||||
**/open-api/**
|
||||
**/openapi/**
|
||||
**/node_modules/**
|
||||
|
||||
generated-api-up-to-date:
|
||||
name: OpenAPI Clients
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
Makefile
2
Makefile
@@ -24,7 +24,7 @@ server-e2e-api:
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
docker compose -f ./docker/docker-compose.e2e.yml up --build -V --remove-orphans
|
||||
|
||||
prod:
|
||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
@@ -21,6 +22,11 @@ module.exports = {
|
||||
'unicorn/prefer-module': 'off',
|
||||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
'unicorn/prevent-abbreviations': 'error',
|
||||
'unicorn/prevent-abbreviations': [
|
||||
'error',
|
||||
{
|
||||
ignore: ['\\.e2e-spec$', /^ignore/i],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
**/*.spec.js
|
||||
test/**
|
||||
upload/**
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine3.19@sha256:c0a3badbd8a0a760de903e00cedbca94588e609299820557e72cba2a53dbaa2c as core
|
||||
FROM node:20-alpine3.19 as core
|
||||
|
||||
WORKDIR /usr/src/open-api/typescript-sdk
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
|
||||
354
cli/package-lock.json
generated
354
cli/package-lock.json
generated
@@ -8,9 +8,6 @@
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.8",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"bin": {
|
||||
"immich": "dist/index.js"
|
||||
},
|
||||
@@ -19,11 +16,10 @@
|
||||
"@testcontainers/postgresql": "^10.7.1",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"byte-size": "^8.1.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -33,6 +29,7 @@
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"immich": "file:../server",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
@@ -67,7 +64,7 @@
|
||||
"../server": {
|
||||
"name": "immich",
|
||||
"version": "1.94.1",
|
||||
"extraneous": true,
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
@@ -137,8 +134,8 @@
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
@@ -1300,21 +1297,6 @@
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.202",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
|
||||
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mock-fs": {
|
||||
"version": "4.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
|
||||
@@ -1340,9 +1322,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
|
||||
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
@@ -1373,16 +1355,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
|
||||
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.5.1",
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/type-utils": "7.0.1",
|
||||
"@typescript-eslint/utils": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/type-utils": "6.21.0",
|
||||
"@typescript-eslint/utils": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.4",
|
||||
@@ -1398,8 +1380,8 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.56.0"
|
||||
"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -1408,15 +1390,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
|
||||
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1427,7 +1409,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -1436,13 +1418,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
|
||||
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
|
||||
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1"
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@@ -1453,13 +1435,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
|
||||
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
|
||||
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/utils": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"@typescript-eslint/utils": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
@@ -1471,7 +1453,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -1480,9 +1462,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
|
||||
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
|
||||
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@@ -1493,13 +1475,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
|
||||
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
|
||||
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -1545,17 +1527,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
|
||||
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
|
||||
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1566,16 +1548,16 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.56.0"
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
|
||||
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
|
||||
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3186,6 +3168,10 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immich": {
|
||||
"resolved": "../server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@@ -3558,11 +3544,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -5240,9 +5221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
|
||||
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz",
|
||||
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.3",
|
||||
@@ -6456,21 +6437,6 @@
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.202",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
|
||||
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"@types/mock-fs": {
|
||||
"version": "4.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
|
||||
@@ -6496,9 +6462,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/semver": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
|
||||
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/ssh2": {
|
||||
@@ -6531,16 +6497,16 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
|
||||
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/regexpp": "^4.5.1",
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/type-utils": "7.0.1",
|
||||
"@typescript-eslint/utils": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/type-utils": "6.21.0",
|
||||
"@typescript-eslint/utils": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.4",
|
||||
@@ -6550,54 +6516,54 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
|
||||
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
|
||||
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
|
||||
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1"
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/type-utils": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
|
||||
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
|
||||
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/utils": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"@typescript-eslint/utils": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
|
||||
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
|
||||
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
|
||||
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
|
||||
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/visitor-keys": "7.0.1",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/visitor-keys": "6.21.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -6627,27 +6593,27 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/utils": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
|
||||
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
|
||||
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@typescript-eslint/scope-manager": "7.0.1",
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/typescript-estree": "7.0.1",
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"@typescript-eslint/typescript-estree": "6.21.0",
|
||||
"semver": "^7.5.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
|
||||
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
|
||||
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "7.0.1",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
}
|
||||
},
|
||||
@@ -7828,6 +7794,99 @@
|
||||
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"immich": {
|
||||
"version": "file:../server",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
"@immich/cli": "^2.0.7",
|
||||
"@nestjs/bullmq": "^10.0.1",
|
||||
"@nestjs/cli": "^10.1.16",
|
||||
"@nestjs/common": "^10.2.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.2.2",
|
||||
"@nestjs/platform-express": "^10.2.2",
|
||||
"@nestjs/platform-socket.io": "^10.2.2",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/testing": "^10.2.2",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@socket.io/postgres-adapter": "^0.3.1",
|
||||
"@testcontainers/postgresql": "^10.2.1",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fluent-ffmpeg": "^2.1.21",
|
||||
"@types/imagemin": "^8.0.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/jest-when": "^3.5.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"archiver": "^6.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^4.8.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"exiftool-vendored": "~24.4.0",
|
||||
"exiftool-vendored.pl": "12.73",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
"glob": "^10.3.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"i18n-iso-countries": "^7.6.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jest": "^29.6.4",
|
||||
"jest-when": "^3.6.0",
|
||||
"joi": "^17.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mock-fs": "^5.2.0",
|
||||
"nest-commander": "^3.11.1",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"openid-client": "^5.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"picomatch": "^4.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.33.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^6.3.3",
|
||||
"testcontainers": "^10.2.1",
|
||||
"thumbhash": "^0.1.1",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^0.3.17",
|
||||
"typescript": "^5.3.3",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"utimes": "^5.2.1"
|
||||
}
|
||||
},
|
||||
"import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@@ -8119,11 +8178,6 @@
|
||||
"p-locate": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
|
||||
},
|
||||
"lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -9364,9 +9418,9 @@
|
||||
}
|
||||
},
|
||||
"vite": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
|
||||
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz",
|
||||
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"esbuild": "^0.19.3",
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
"@testcontainers/postgresql": "^10.7.1",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"byte-size": "^8.1.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
@@ -31,6 +30,7 @@
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"immich": "file:../server",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
@@ -41,14 +41,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"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",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"test:e2e": "vitest --config test/e2e/vitest.config.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -57,8 +58,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { AssetBulkUploadCheckResult } from '@immich/sdk';
|
||||
import byteSize from 'byte-size';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { chunk, zip } from 'lodash-es';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs, { createReadStream } from 'node:fs';
|
||||
import { access, constants, stat, unlink } from 'node:fs/promises';
|
||||
@@ -11,23 +9,15 @@ import { ImmichApi } from 'src/services/api.service';
|
||||
import { CrawlService } from '../services/crawl.service';
|
||||
import { BaseCommand } from './base-command';
|
||||
|
||||
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
|
||||
|
||||
enum CheckResponseStatus {
|
||||
ACCEPT = 'accept',
|
||||
REJECT = 'reject',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
|
||||
class Asset {
|
||||
readonly path: string;
|
||||
readonly deviceId!: string;
|
||||
|
||||
id?: string;
|
||||
deviceAssetId?: string;
|
||||
fileCreatedAt?: Date;
|
||||
fileModifiedAt?: Date;
|
||||
sidecarPath?: string;
|
||||
fileSize?: number;
|
||||
fileSize!: number;
|
||||
albumName?: string;
|
||||
|
||||
constructor(path: string) {
|
||||
@@ -115,141 +105,17 @@ export class UploadOptionsDto {
|
||||
album? = false;
|
||||
albumName? = '';
|
||||
includeHidden? = false;
|
||||
concurrency? = 4;
|
||||
}
|
||||
|
||||
export class UploadCommand extends BaseCommand {
|
||||
api!: ImmichApi;
|
||||
uploadLength!: number;
|
||||
|
||||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
||||
this.api = await this.connect();
|
||||
const api = await this.connect();
|
||||
|
||||
console.log('Crawling for assets...');
|
||||
const files = await this.getFiles(paths, options);
|
||||
const formatResponse = await api.getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToCheck = files.map((path) => new Asset(path));
|
||||
|
||||
const { newAssets, duplicateAssets } = await this.checkAssets(assetsToCheck, options.concurrency ?? 4);
|
||||
|
||||
const totalSizeUploaded = await this.upload(newAssets, options);
|
||||
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
|
||||
if (newAssets.length === 0) {
|
||||
console.log('All assets were already uploaded, nothing to do.');
|
||||
} else {
|
||||
console.log(
|
||||
`${messageStart} uploaded ${newAssets.length} asset${newAssets.length === 1 ? '' : 's'} (${byteSize(totalSizeUploaded)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.album || options.albumName) {
|
||||
const { createdAlbumCount, updatedAssetCount } = await this.updateAlbums(
|
||||
[...newAssets, ...duplicateAssets],
|
||||
options,
|
||||
);
|
||||
console.log(`${messageStart} created ${createdAlbumCount} new album${createdAlbumCount === 1 ? '' : 's'}`);
|
||||
console.log(`${messageStart} updated ${updatedAssetCount} asset${updatedAssetCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
if (!options.delete) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Would now have deleted assets, but skipped due to dry run`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
|
||||
await this.deleteAssets(newAssets, options);
|
||||
}
|
||||
|
||||
public async checkAssets(
|
||||
assetsToCheck: Asset[],
|
||||
concurrency: number,
|
||||
): Promise<{ newAssets: Asset[]; duplicateAssets: Asset[]; rejectedAssets: Asset[] }> {
|
||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
||||
await Promise.all(assets.map((asset: Asset) => asset.prepare()));
|
||||
}
|
||||
|
||||
const checkProgress = new cliProgress.SingleBar(
|
||||
{ format: 'Checking assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
checkProgress.start(assetsToCheck.length, 0);
|
||||
|
||||
const newAssets = [];
|
||||
const duplicateAssets = [];
|
||||
const rejectedAssets = [];
|
||||
try {
|
||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
||||
const checkedAssets = await this.getStatus(assets);
|
||||
for (const checked of checkedAssets) {
|
||||
if (checked.status === CheckResponseStatus.ACCEPT) {
|
||||
newAssets.push(checked.asset);
|
||||
} else if (checked.status === CheckResponseStatus.DUPLICATE) {
|
||||
duplicateAssets.push(checked.asset);
|
||||
} else {
|
||||
rejectedAssets.push(checked.asset);
|
||||
}
|
||||
checkProgress.increment();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
checkProgress.stop();
|
||||
}
|
||||
|
||||
return { newAssets, duplicateAssets, rejectedAssets };
|
||||
}
|
||||
|
||||
public async upload(assetsToUpload: Asset[], options: UploadOptionsDto): Promise<number> {
|
||||
let totalSize = 0;
|
||||
|
||||
// Compute total size first
|
||||
for (const asset of assetsToUpload) {
|
||||
totalSize += asset.fileSize ?? 0;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
uploadProgress.start(totalSize, 0);
|
||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||
|
||||
let totalSizeUploaded = 0;
|
||||
try {
|
||||
for (const assets of chunk(assetsToUpload, options.concurrency)) {
|
||||
const ids = await this.uploadAssets(assets);
|
||||
for (const [asset, id] of zipDefined(assets, ids)) {
|
||||
asset.id = id;
|
||||
if (asset.fileSize) {
|
||||
totalSizeUploaded += asset.fileSize ?? 0;
|
||||
} else {
|
||||
console.log(`Could not determine file size for ${asset.path}`);
|
||||
}
|
||||
}
|
||||
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
|
||||
}
|
||||
} finally {
|
||||
uploadProgress.stop();
|
||||
}
|
||||
|
||||
return totalSizeUploaded;
|
||||
}
|
||||
|
||||
public async getFiles(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
||||
const inputFiles: string[] = [];
|
||||
for (const pathArgument of paths) {
|
||||
const fileStat = await fs.promises.lstat(pathArgument);
|
||||
@@ -258,187 +124,151 @@ export class UploadCommand extends BaseCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const files: string[] = await this.crawl(paths, options);
|
||||
files.push(...inputFiles);
|
||||
return files;
|
||||
}
|
||||
|
||||
public async getAlbums(): Promise<Map<string, string>> {
|
||||
const existingAlbums = await this.api.getAllAlbums();
|
||||
|
||||
const albumMapping = new Map<string, string>();
|
||||
for (const album of existingAlbums) {
|
||||
albumMapping.set(album.albumName, album.id);
|
||||
}
|
||||
|
||||
return albumMapping;
|
||||
}
|
||||
|
||||
public async updateAlbums(
|
||||
assets: Asset[],
|
||||
options: UploadOptionsDto,
|
||||
): Promise<{ createdAlbumCount: number; updatedAssetCount: number }> {
|
||||
if (options.albumName) {
|
||||
for (const asset of assets) {
|
||||
asset.albumName = options.albumName;
|
||||
}
|
||||
}
|
||||
|
||||
const existingAlbums = await this.getAlbums();
|
||||
const assetsToUpdate = assets.filter(
|
||||
(asset): asset is Asset & { albumName: string; id: string } => !!(asset.albumName && asset.id),
|
||||
);
|
||||
|
||||
const newAlbumsSet: Set<string> = new Set();
|
||||
for (const asset of assetsToUpdate) {
|
||||
if (!existingAlbums.has(asset.albumName)) {
|
||||
newAlbumsSet.add(asset.albumName);
|
||||
}
|
||||
}
|
||||
|
||||
const newAlbums = [...newAlbumsSet];
|
||||
|
||||
if (options.dryRun) {
|
||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
||||
}
|
||||
|
||||
const albumCreationProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
albumCreationProgress.start(newAlbums.length, 0);
|
||||
|
||||
try {
|
||||
for (const albumNames of chunk(newAlbums, options.concurrency)) {
|
||||
const newAlbumIds = await Promise.all(
|
||||
albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)),
|
||||
);
|
||||
|
||||
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
|
||||
existingAlbums.set(albumName, albumId);
|
||||
}
|
||||
|
||||
albumCreationProgress.increment(albumNames.length);
|
||||
}
|
||||
} finally {
|
||||
albumCreationProgress.stop();
|
||||
}
|
||||
|
||||
const albumToAssets = new Map<string, string[]>();
|
||||
for (const asset of assetsToUpdate) {
|
||||
const albumId = existingAlbums.get(asset.albumName);
|
||||
if (albumId) {
|
||||
if (!albumToAssets.has(albumId)) {
|
||||
albumToAssets.set(albumId, []);
|
||||
}
|
||||
albumToAssets.get(albumId)?.push(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
const albumUpdateProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
albumUpdateProgress.start(assetsToUpdate.length, 0);
|
||||
|
||||
try {
|
||||
for (const [albumId, assets] of albumToAssets.entries()) {
|
||||
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
|
||||
await this.api.addAssetsToAlbum(albumId, { ids: assetBatch });
|
||||
albumUpdateProgress.increment(assetBatch.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
albumUpdateProgress.stop();
|
||||
}
|
||||
|
||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
||||
}
|
||||
|
||||
public async deleteAssets(assets: Asset[], options: UploadOptionsDto): Promise<void> {
|
||||
const deletionProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
deletionProgress.start(assets.length, 0);
|
||||
|
||||
try {
|
||||
for (const assetBatch of chunk(assets, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((asset: Asset) => asset.delete()));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
} finally {
|
||||
deletionProgress.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async getStatus(assets: Asset[]): Promise<{ asset: Asset; status: CheckResponseStatus }[]> {
|
||||
const checkResponse = await this.checkHashes(assets);
|
||||
|
||||
const responses = [];
|
||||
for (const [check, asset] of zipDefined(checkResponse, assets)) {
|
||||
if (check.assetId) {
|
||||
asset.id = check.assetId;
|
||||
}
|
||||
|
||||
if (check.action === 'accept') {
|
||||
responses.push({ asset, status: CheckResponseStatus.ACCEPT });
|
||||
} else if (check.reason === 'duplicate') {
|
||||
responses.push({ asset, status: CheckResponseStatus.DUPLICATE });
|
||||
} else {
|
||||
responses.push({ asset, status: CheckResponseStatus.REJECT });
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
private async checkHashes(assetsToCheck: Asset[]): Promise<AssetBulkUploadCheckResult[]> {
|
||||
const checksums = await Promise.all(assetsToCheck.map((asset) => asset.hash()));
|
||||
const assetBulkUploadCheckDto = {
|
||||
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
|
||||
};
|
||||
const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto);
|
||||
return checkResponse.results;
|
||||
}
|
||||
|
||||
private async uploadAssets(assets: Asset[]): Promise<string[]> {
|
||||
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
|
||||
return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id)));
|
||||
}
|
||||
|
||||
private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
||||
const formatResponse = await this.api.getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
|
||||
|
||||
return crawlService.crawl({
|
||||
const files: string[] = await crawlService.crawl({
|
||||
pathsToCrawl: paths,
|
||||
recursive: options.recursive,
|
||||
exclusionPatterns: options.exclusionPatterns,
|
||||
includeHidden: options.includeHidden,
|
||||
});
|
||||
|
||||
files.push(...inputFiles);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToUpload = files.map((path) => new Asset(path));
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: '{bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}: {filename}',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
|
||||
let totalSize = 0;
|
||||
let sizeSoFar = 0;
|
||||
|
||||
let totalSizeUploaded = 0;
|
||||
let uploadCounter = 0;
|
||||
|
||||
for (const asset of assetsToUpload) {
|
||||
// Compute total size first
|
||||
await asset.prepare();
|
||||
totalSize += asset.fileSize;
|
||||
|
||||
if (options.albumName) {
|
||||
asset.albumName = options.albumName;
|
||||
}
|
||||
}
|
||||
|
||||
const existingAlbums = await api.getAllAlbums();
|
||||
|
||||
uploadProgress.start(totalSize, 0);
|
||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||
|
||||
try {
|
||||
for (const asset of assetsToUpload) {
|
||||
uploadProgress.update({
|
||||
filename: asset.path,
|
||||
});
|
||||
|
||||
let skipUpload = false;
|
||||
|
||||
let skipAsset = false;
|
||||
let existingAssetId: string | undefined = undefined;
|
||||
|
||||
if (!options.skipHash) {
|
||||
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
|
||||
|
||||
const checkResponse = await api.checkBulkUpload(assetBulkUploadCheckDto);
|
||||
|
||||
skipUpload = checkResponse.results[0].action === 'reject';
|
||||
|
||||
const isDuplicate = checkResponse.results[0].reason === 'duplicate';
|
||||
if (isDuplicate) {
|
||||
existingAssetId = checkResponse.results[0].assetId;
|
||||
}
|
||||
|
||||
skipAsset = skipUpload && !isDuplicate;
|
||||
}
|
||||
|
||||
if (!skipAsset && !options.dryRun) {
|
||||
if (!skipUpload) {
|
||||
const formData = await asset.getUploadFormData();
|
||||
const response = await this.uploadAsset(api, formData);
|
||||
const json = await response.json();
|
||||
existingAssetId = json.id;
|
||||
uploadCounter++;
|
||||
totalSizeUploaded += asset.fileSize;
|
||||
}
|
||||
|
||||
if ((options.album || options.albumName) && asset.albumName !== undefined) {
|
||||
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
|
||||
if (!album) {
|
||||
const response = await api.createAlbum({ albumName: asset.albumName });
|
||||
album = response;
|
||||
existingAlbums.push(album);
|
||||
}
|
||||
|
||||
if (existingAssetId) {
|
||||
await api.addAssetsToAlbum(album.id, {
|
||||
ids: [existingAssetId],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sizeSoFar += asset.fileSize;
|
||||
|
||||
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
|
||||
}
|
||||
} finally {
|
||||
uploadProgress.stop();
|
||||
}
|
||||
|
||||
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
|
||||
|
||||
if (uploadCounter === 0) {
|
||||
console.log('All assets were already uploaded, nothing to do.');
|
||||
} else {
|
||||
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
|
||||
}
|
||||
if (options.delete) {
|
||||
if (options.dryRun) {
|
||||
console.log(`Would now have deleted assets, but skipped due to dry run`);
|
||||
} else {
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
|
||||
deletionProgress.start(files.length, 0);
|
||||
|
||||
for (const asset of assetsToUpload) {
|
||||
if (!options.dryRun) {
|
||||
await asset.delete();
|
||||
}
|
||||
deletionProgress.increment();
|
||||
}
|
||||
deletionProgress.stop();
|
||||
console.log('Deletion complete');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadAsset(data: FormData): Promise<{ id: string }> {
|
||||
const url = this.api.instanceUrl + '/asset/upload';
|
||||
private async uploadAsset(api: ImmichApi, data: FormData): Promise<Response> {
|
||||
const url = api.instanceUrl + '/asset/upload';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'post',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'x-api-key': this.api.apiKey,
|
||||
'x-api-key': api.apiKey,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const program = new Command()
|
||||
.version(version)
|
||||
.description('Command line interface for Immich')
|
||||
.addOption(
|
||||
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
|
||||
new Option('-d, --config-directory', 'Configuration directory where auth.yml will be stored')
|
||||
.env('IMMICH_CONFIG_DIR')
|
||||
.default(defaultConfigDirectory),
|
||||
);
|
||||
@@ -43,11 +43,6 @@ program
|
||||
.env('IMMICH_DRY_RUN')
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-c, --concurrency', 'Number of assets to upload at the same time')
|
||||
.env('IMMICH_UPLOAD_CONCURRENCY')
|
||||
.default(4),
|
||||
)
|
||||
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||
.action(async (paths, options) => {
|
||||
@@ -65,10 +60,10 @@ program
|
||||
program
|
||||
.command('login-key')
|
||||
.description('Login using an API key')
|
||||
.argument('url')
|
||||
.argument('key')
|
||||
.action(async (url, key) => {
|
||||
await new LoginCommand(program.opts()).run(url, key);
|
||||
.argument('[instanceUrl]')
|
||||
.argument('[apiKey]')
|
||||
.action(async (paths, options) => {
|
||||
await new LoginCommand(program.opts()).run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
import {
|
||||
TEST_AUTH_FILE,
|
||||
TEST_CONFIG_DIR,
|
||||
TEST_IMMICH_API_KEY,
|
||||
TEST_IMMICH_INSTANCE_URL,
|
||||
createTestAuthFile,
|
||||
deleteAuthFile,
|
||||
readTestAuthFile,
|
||||
spyOnConsole,
|
||||
} from '../../test/cli-test-utils';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
const TEST_CONFIG_DIR = '/tmp/immich/';
|
||||
const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
|
||||
const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
|
||||
const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
|
||||
|
||||
const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const readTestAuthFile = async (): Promise<string> => {
|
||||
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
|
||||
};
|
||||
|
||||
const deleteAuthFile = () => {
|
||||
try {
|
||||
fs.unlinkSync(TEST_AUTH_FILE);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
|
||||
|
||||
52
cli/test/cli-test-utils.ts
Normal file
52
cli/test/cli-test-utils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ImmichApi } from 'src/services/api.service';
|
||||
|
||||
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 = { configDirectory: TEST_CONFIG_DIR };
|
||||
|
||||
export const setup = async () => {
|
||||
const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, '');
|
||||
await api.signUpAdmin({ email: 'cli@immich.app', password: 'password', name: 'Administrator' });
|
||||
const admin = await api.login({ email: 'cli@immich.app', password: 'password' });
|
||||
const apiKey = await api.createApiKey(
|
||||
{ name: 'CLI Test' },
|
||||
{ headers: { Authorization: `Bearer ${admin.accessToken}` } },
|
||||
);
|
||||
|
||||
api.setApiKey(apiKey.secret);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
export const spyOnConsole = () => vi.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;
|
||||
}
|
||||
}
|
||||
};
|
||||
65
cli/test/e2e/login-key.e2e-spec.ts
Normal file
65
cli/test/e2e/login-key.e2e-spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { restoreTempFolder, testApp } from '@test-utils';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { CLI_BASE_OPTIONS, TEST_AUTH_FILE, deleteAuthFile, setup, spyOnConsole } from 'test/cli-test-utils';
|
||||
import yaml from 'yaml';
|
||||
import { LoginCommand } from '../../src/commands/login.command';
|
||||
|
||||
describe(`login-key (e2e)`, () => {
|
||||
let apiKey: string;
|
||||
let instanceUrl: string;
|
||||
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testApp.create();
|
||||
if (process.env.IMMICH_INSTANCE_URL) {
|
||||
instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
} else {
|
||||
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
deleteAuthFile();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
|
||||
const api = await setup();
|
||||
apiKey = api.apiKey;
|
||||
|
||||
deleteAuthFile();
|
||||
});
|
||||
|
||||
it('should error when providing an invalid API key', async () => {
|
||||
await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
|
||||
`Failed to connect to server ${instanceUrl}: Error: 401`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log in when providing the correct API key', async () => {
|
||||
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
|
||||
});
|
||||
|
||||
it('should create an auth file when logging in', async () => {
|
||||
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
|
||||
|
||||
const data: string = await readFile(TEST_AUTH_FILE, 'utf8');
|
||||
const parsedConfig = yaml.parse(data);
|
||||
|
||||
expect(parsedConfig).toEqual(expect.objectContaining({ instanceUrl, apiKey }));
|
||||
});
|
||||
|
||||
it('should create an auth file with chmod 600', async () => {
|
||||
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
|
||||
|
||||
const stats = await stat(TEST_AUTH_FILE);
|
||||
const mode = (stats.mode & 0o777).toString(8);
|
||||
|
||||
expect(mode).toEqual('600');
|
||||
});
|
||||
});
|
||||
34
cli/test/e2e/server-info.e2e-spec.ts
Normal file
34
cli/test/e2e/server-info.e2e-spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { restoreTempFolder, testApp } from '@test-utils';
|
||||
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
|
||||
import { ServerInfoCommand } from '../../src/commands/server-info.command';
|
||||
|
||||
describe(`server-info (e2e)`, () => {
|
||||
const consoleSpy = spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testApp.create();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
const api = await setup();
|
||||
process.env.IMMICH_API_KEY = api.apiKey;
|
||||
});
|
||||
|
||||
it('should show server version', async () => {
|
||||
await new ServerInfoCommand(CLI_BASE_OPTIONS).run();
|
||||
|
||||
expect(consoleSpy.mock.calls).toEqual([
|
||||
[expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))],
|
||||
[expect.stringMatching('Image Types: .*')],
|
||||
[expect.stringMatching('Video Types: .*')],
|
||||
['Statistics:\n Images: 0\n Videos: 0\n Total: 0'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
42
cli/test/e2e/setup.ts
Normal file
42
cli/test/e2e/setup.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const directoryExists = (directory: string) =>
|
||||
access(directory)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.2.0')
|
||||
.withExposedPorts(5432)
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
.withPassword('postgres')
|
||||
.withReuse()
|
||||
.start();
|
||||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/e2e/jobs/immich-e2e-config.json`);
|
||||
process.env.TZ = 'Z';
|
||||
};
|
||||
79
cli/test/e2e/upload.e2e-spec.ts
Normal file
79
cli/test/e2e/upload.e2e-spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test-utils';
|
||||
import { ImmichApi } from 'src/services/api.service';
|
||||
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
|
||||
import { UploadCommand } from '../../src/commands/upload.command';
|
||||
|
||||
describe(`upload (e2e)`, () => {
|
||||
let api: ImmichApi;
|
||||
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testApp.create();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
api = await setup();
|
||||
process.env.IMMICH_API_KEY = api.apiKey;
|
||||
});
|
||||
|
||||
it('should upload a folder recursively', async () => {
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const assets = await api.getAllAssets();
|
||||
expect(assets.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('should not create a new album', async () => {
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const albums = await api.getAllAlbums();
|
||||
expect(albums.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should create album from folder name', async () => {
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
|
||||
const albums = await api.getAllAlbums();
|
||||
expect(albums.length).toEqual(1);
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should add existing assets to album', async () => {
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// upload again, but this time add to album
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
|
||||
const albums = await api.getAllAlbums();
|
||||
expect(albums.length).toEqual(1);
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should upload to the specified album name', async () => {
|
||||
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
albumName: 'testAlbum',
|
||||
});
|
||||
|
||||
const albums = await api.getAllAlbums();
|
||||
expect(albums.length).toEqual(1);
|
||||
const testAlbum = albums[0];
|
||||
expect(testAlbum.albumName).toEqual('testAlbum');
|
||||
});
|
||||
});
|
||||
22
cli/test/e2e/vitest.config.ts
Normal file
22
cli/test/e2e/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@test-utils': new URL('../../../server/dist/test-utils/utils.js', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['**/*.e2e-spec.ts'],
|
||||
globals: true,
|
||||
globalSetup: 'test/e2e/setup.ts',
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
maxForks: 1,
|
||||
minForks: 1,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
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';
|
||||
};
|
||||
3
cli/testSetup.js
Normal file
3
cli/testSetup.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// add all jest-extended matchers
|
||||
import * as matchers from 'jest-extended';
|
||||
expect.extend(matchers);
|
||||
@@ -13,7 +13,6 @@ x-server-build: &server-common
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=immich
|
||||
- REDIS_HOSTNAME=redis
|
||||
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||
volumes:
|
||||
- upload:/usr/src/app/upload
|
||||
depends_on:
|
||||
@@ -27,22 +26,23 @@ services:
|
||||
ports:
|
||||
- 2283:3001
|
||||
|
||||
# immich-microservices:
|
||||
# command: [ "./start.sh", "microservices" ]
|
||||
# <<: *server-common
|
||||
immich-microservices:
|
||||
command: [ "./start.sh", "microservices" ]
|
||||
<<: *server-common
|
||||
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
||||
restart: always
|
||||
|
||||
database:
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
command: -c fsync=off -c shared_preload_libraries=vectors.so
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: immich
|
||||
ports:
|
||||
- 5433:5432
|
||||
- 5432:5432
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
@@ -7,7 +7,7 @@ When contributing code through a pull request, please check the following:
|
||||
- [ ] `npm run lint` (linting via ESLint)
|
||||
- [ ] `npm run format` (formatting via Prettier)
|
||||
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
|
||||
- [ ] `npm test` (unit tests)
|
||||
- [ ] `npm test` (Tests via Jest)
|
||||
|
||||
:::tip
|
||||
Run all web checks with `npm run check:all`
|
||||
@@ -18,7 +18,7 @@ Run all web checks with `npm run check:all`
|
||||
- [ ] `npm run lint` (linting via ESLint)
|
||||
- [ ] `npm run format` (formatting via Prettier)
|
||||
- [ ] `npm run check` (Type checking via `tsc`)
|
||||
- [ ] `npm test` (unit tests)
|
||||
- [ ] `npm test` (Tests via Jest)
|
||||
|
||||
:::tip
|
||||
Run all server checks with `npm run check:all`
|
||||
|
||||
@@ -42,24 +42,23 @@ Finally, files can be deleted from Immich via the `Remove Offline Files` job. Th
|
||||
|
||||
### Import Paths
|
||||
|
||||
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible.
|
||||
|
||||
If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
|
||||
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Sometimes, an external library will not scan correctly. This can happen if immich_server or immich_microservices can't access the files. Here are some things to check:
|
||||
Sometimes, an external library will not scan correctly. This can happen if the immich_server or immich_microservices can't access the files. Here are some things to check:
|
||||
|
||||
- Is the external path set correctly? Each import path must be contained in the external path.
|
||||
- Make sure the external path does not contain spaces
|
||||
- Is the external path set correctly?
|
||||
- In the docker-compose file, are the volumes mounted correctly?
|
||||
- Are the volumes identical between the `server` and `microservices` container?
|
||||
- Are the import paths set correctly, and do they match the path set in docker-compose file?
|
||||
- Make sure you don't use symlinks in your import libraries, and that you aren't linking across docker mounts.
|
||||
- Are you using symbolic link in your import library?
|
||||
- Are the permissions set correctly?
|
||||
- Make sure you are using forward slashes (`/`) and not backward slashes.
|
||||
- Are you using forward slashes everywhere? (`/`)
|
||||
- Are you using symlink across docker mounts?
|
||||
- Are you using [spaces in the internal path](/docs/features/libraries#:~:text=can%20be%20accessed.-,NOTE,-Spaces%20in%20the)?
|
||||
|
||||
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_microservices /bin/bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the `immich_server` container. If you cannot access this directory in both the `microservices` and `server` containers, Immich won't be able to import files.
|
||||
If all else fails, you can always start a shell inside the container and check if the path is accessible. For example, `docker exec -it immich_microservices /bin/bash` will start a bash shell. If your import path, for instance, is `/data/import/photos`, you can check if the files are accessible by running `ls /data/import/photos`. Also check the `immich_server` container in the same way.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
|
||||
@@ -60,17 +60,14 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## Database
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :---------------------------------- | :------------------------------------------------------------ | :----------: | :-------------------- |
|
||||
| `DB_URL` | Database URL | | server, microservices |
|
||||
| `DB_HOSTNAME` | Database Host | `localhost` | server, microservices |
|
||||
| `DB_PORT` | Database Port | `5432` | server, microservices |
|
||||
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
|
||||
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
|
||||
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
|
||||
| `DB_VECTOR_EXTENSION`<sup>\*1</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server, microservices |
|
||||
|
||||
\*1: This setting cannot be changed after the server has successfully started up
|
||||
| Variable | Description | Default | Services |
|
||||
| :------------ | :---------------- | :---------: | :-------------------- |
|
||||
| `DB_URL` | Database URL | | server, microservices |
|
||||
| `DB_HOSTNAME` | Database Host | `localhost` | server, microservices |
|
||||
| `DB_PORT` | Database Port | `5432` | server, microservices |
|
||||
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
|
||||
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
|
||||
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
|
||||
|
||||
:::info
|
||||
|
||||
|
||||
6
docs/package-lock.json
generated
6
docs/package-lock.json
generated
@@ -2964,9 +2964,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mdx-js/react": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz",
|
||||
"integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.0.tgz",
|
||||
"integrity": "sha512-nDctevR9KyYFyV+m+/+S4cpzCWHqj+iHDHq3QrsWezcC+B17uZdIWgCguESUkwFhM3n/56KxWVE3V6EokrmONQ==",
|
||||
"dependencies": {
|
||||
"@types/mdx": "^2.0.0"
|
||||
},
|
||||
|
||||
2388
e2e/package-lock.json
generated
2388
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,24 +5,18 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --config vitest.config.ts",
|
||||
"test:web": "npx playwright test",
|
||||
"start:web": "npx playwright test --ui"
|
||||
"test": "npx playwright test",
|
||||
"build": "tsc"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@immich/cli": "file:../cli",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^1.3.0",
|
||||
"pg": "^8.11.3",
|
||||
"supertest": "^6.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.0"
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/web/specs',
|
||||
testDir: './specs/',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
@@ -53,7 +53,8 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'docker compose up --build -V --remove-orphans',
|
||||
command:
|
||||
'docker compose -f ../docker/docker-compose.e2e.yml up --build -V --remove-orphans',
|
||||
url: 'http://127.0.0.1:2283',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { apiUtils, dbUtils, webUtils } from 'src/utils';
|
||||
import { app } from '../test-utils';
|
||||
|
||||
test.describe('Registration', () => {
|
||||
test.beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await app.reset();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await dbUtils.teardown();
|
||||
await app.teardown();
|
||||
});
|
||||
|
||||
test('admin registration', async ({ page }) => {
|
||||
@@ -45,8 +41,7 @@ test.describe('Registration', () => {
|
||||
});
|
||||
|
||||
test('user registration', async ({ context, page }) => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
await webUtils.setAuthCookies(context, admin.accessToken);
|
||||
await app.adminSetup(context);
|
||||
|
||||
// create user
|
||||
await page.goto('/admin/user-management');
|
||||
@@ -1,291 +0,0 @@
|
||||
import {
|
||||
LoginResponseDto,
|
||||
getAuthDevices,
|
||||
login,
|
||||
signUpAdmin,
|
||||
} from '@immich/sdk';
|
||||
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
|
||||
import {
|
||||
deviceDto,
|
||||
errorDto,
|
||||
loginResponseDto,
|
||||
signupResponseDto,
|
||||
} from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const { name, email, password } = signupDto.admin;
|
||||
|
||||
describe(`/auth/admin-sign-up`, () => {
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
const invalid = [
|
||||
{
|
||||
should: 'require an email address',
|
||||
data: { name, password },
|
||||
},
|
||||
{
|
||||
should: 'require a password',
|
||||
data: { name, email },
|
||||
},
|
||||
{
|
||||
should: 'require a name',
|
||||
data: { email, password },
|
||||
},
|
||||
{
|
||||
should: 'require a valid email',
|
||||
data: { name, email: 'immich', password },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it(`should sign up the admin`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(signupDto.admin);
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(signupResponseDto.admin);
|
||||
});
|
||||
|
||||
it('should sign up the admin with a local domain', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...signupDto.admin, email: 'admin@local' });
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual({
|
||||
...signupResponseDto.admin,
|
||||
email: 'admin@local',
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...signupDto.admin, email: 'aDmIn@IMMICH.cloud' });
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual(signupResponseDto.admin);
|
||||
});
|
||||
|
||||
it('should not allow a second admin to sign up', async () => {
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send(signupDto.admin);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.alreadyHasAdmin);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/auth/*', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
admin = await login({ loginCredentialDto: loginDto.admin });
|
||||
});
|
||||
|
||||
describe(`POST /auth/login`, () => {
|
||||
it('should reject an incorrect password', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'incorrect' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.incorrectLogin);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(loginDto.admin)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({ ...loginDto.admin, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should accept a correct password', async () => {
|
||||
const { status, body, headers } = await request(app)
|
||||
.post('/auth/login')
|
||||
.send({ email, password });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(loginResponseDto.admin);
|
||||
|
||||
const token = body.accessToken;
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const cookies = headers['set-cookie'];
|
||||
expect(cookies).toHaveLength(3);
|
||||
expect(cookies[0]).toEqual(
|
||||
`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`
|
||||
);
|
||||
expect(cookies[1]).toEqual(
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'
|
||||
);
|
||||
expect(cookies[2]).toEqual(
|
||||
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/devices', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/auth/devices');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get a list of authorized devices', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/auth/devices')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([deviceDto.current]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /auth/devices', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(`/auth/devices`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should logout all devices (except the current one)', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await login({ loginCredentialDto: loginDto.admin });
|
||||
}
|
||||
|
||||
await expect(
|
||||
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
|
||||
).resolves.toHaveLength(6);
|
||||
|
||||
const { status } = await request(app)
|
||||
.delete(`/auth/devices`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
await expect(
|
||||
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw an error for a non-existent device id', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/auth/devices/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest('Not found or no authDevice.delete access')
|
||||
);
|
||||
});
|
||||
|
||||
it('should logout a device', async () => {
|
||||
const [device] = await getAuthDevices({
|
||||
headers: asBearerAuth(admin.accessToken),
|
||||
});
|
||||
const { status } = await request(app)
|
||||
.delete(`/auth/devices/${device.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/auth/validateToken')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(response.body).toEqual(errorDto.invalidToken);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/validateToken', () => {
|
||||
it('should reject an invalid token', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/validateToken`)
|
||||
.set('Authorization', 'Bearer 123');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.invalidToken);
|
||||
});
|
||||
|
||||
it('should accept a valid token', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/validateToken`)
|
||||
.send({})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ authStatus: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/change-password', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/change-password`)
|
||||
.send({ password, newPassword: 'Password1234' });
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should require the current password', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/change-password`)
|
||||
.send({ password: 'wrong-password', newPassword: 'Password1234' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.wrongPassword);
|
||||
});
|
||||
|
||||
it('should change the password', async () => {
|
||||
const { status } = await request(app)
|
||||
.post(`/auth/change-password`)
|
||||
.send({ password, newPassword: 'Password1234' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
|
||||
await login({
|
||||
loginCredentialDto: {
|
||||
email: 'admin@immich.cloud',
|
||||
password: 'Password1234',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(`/auth/logout`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should logout the user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/auth/logout`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { AssetResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/download', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset1: AssetResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup();
|
||||
asset1 = await apiUtils.createAsset(admin.accessToken);
|
||||
});
|
||||
|
||||
describe('POST /download/info', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/download/info`)
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/download/info')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
archives: [expect.objectContaining({ assetIds: [asset1.id] })],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(
|
||||
`/download/asset/${asset1.id}`
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should download file', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/download/asset/${asset1.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toEqual('image/jpeg');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`/oauth`, () => {
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await apiUtils.adminSetup();
|
||||
});
|
||||
|
||||
describe('POST /oauth/authorize', () => {
|
||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/oauth/authorize')
|
||||
.send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'redirectUri must be a string',
|
||||
'redirectUri should not be empty',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
import {
|
||||
LoginResponseDto,
|
||||
UserResponseDto,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
} from '@immich/sdk';
|
||||
import { createUserDto, userDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/server-info', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
});
|
||||
|
||||
describe('GET /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/user');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should start with the admin', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/user')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
|
||||
});
|
||||
|
||||
it('should hide deleted users', async () => {
|
||||
const user1 = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
await deleteUser(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user`)
|
||||
.query({ isAll: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
|
||||
});
|
||||
|
||||
it('should include deleted users', async () => {
|
||||
const user1 = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
await deleteUser(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user`)
|
||||
.query({ isAll: false })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]).toMatchObject({
|
||||
id: user1.userId,
|
||||
email: 'user1@immich.cloud',
|
||||
deletedAt: expect.any(String),
|
||||
});
|
||||
expect(body[1]).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /user/info/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(app).get(`/user/info/${admin.userId}`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
|
||||
it('should get the user info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user/info/${admin.userId}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /user/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/user/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get my info', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/user/me`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
id: admin.userId,
|
||||
email: 'admin@immich.cloud',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/user`)
|
||||
.send(createUserDto.user1);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(createUserDto.user1)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/user`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...createUserDto.user1, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should ignore `isAdmin`', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/user`)
|
||||
.send({
|
||||
isAdmin: true,
|
||||
email: 'user1@immich.cloud',
|
||||
password: 'Password123',
|
||||
name: 'Immich',
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toMatchObject({
|
||||
email: 'user1@immich.cloud',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should create a user without memories enabled', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/user`)
|
||||
.send({
|
||||
email: 'no-memories@immich.cloud',
|
||||
password: 'Password123',
|
||||
name: 'No Memories',
|
||||
memoriesEnabled: false,
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(body).toMatchObject({
|
||||
email: 'no-memories@immich.cloud',
|
||||
memoriesEnabled: false,
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /user/:id', () => {
|
||||
let userToDelete: UserResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
userToDelete = await createUser(
|
||||
{ createUserDto: createUserDto.user1 },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/user/${userToDelete.id}`
|
||||
);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/user/${userToDelete.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...userToDelete,
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/user`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(userDto.admin)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ...userDto.admin, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should not allow a non-admin to become an admin', async () => {
|
||||
const user = await apiUtils.userSetup(
|
||||
admin.accessToken,
|
||||
createUserDto.user1
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({ isAdmin: true, id: user.userId })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.alreadyHasAdmin);
|
||||
});
|
||||
|
||||
it('ignores updates to profileImagePath', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({ id: admin.userId, profileImagePath: 'invalid.jpg' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
|
||||
});
|
||||
|
||||
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({
|
||||
id: admin.userId,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
deletedAt: '2023-01-01T00:00:00.000Z',
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toStrictEqual(before);
|
||||
});
|
||||
|
||||
it('should update first and last name', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({
|
||||
id: admin.userId,
|
||||
name: 'Name',
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
...before,
|
||||
updatedAt: expect.any(String),
|
||||
name: 'Name',
|
||||
});
|
||||
expect(before.updatedAt).not.toEqual(body.updatedAt);
|
||||
});
|
||||
|
||||
it('should update memories enabled', async () => {
|
||||
const before = await getUserById(
|
||||
{ id: admin.userId },
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
const { status, body } = await request(app)
|
||||
.put(`/user`)
|
||||
.send({
|
||||
id: admin.userId,
|
||||
memoriesEnabled: false,
|
||||
})
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
...before,
|
||||
updatedAt: expect.anything(),
|
||||
memoriesEnabled: false,
|
||||
});
|
||||
expect(before.updatedAt).not.toEqual(body.updatedAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
|
||||
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich login-key`, () => {
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
});
|
||||
|
||||
it('should require a url', async () => {
|
||||
const { stderr, exitCode } = await immichCli(['login-key']);
|
||||
expect(stderr).toBe("error: missing required argument 'url'");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { stderr, exitCode } = await immichCli(['login-key', app]);
|
||||
expect(stderr).toBe("error: missing required argument 'key'");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { stderr, exitCode } = await immichCli([
|
||||
'login-key',
|
||||
app,
|
||||
'immich-is-so-cool',
|
||||
]);
|
||||
expect(stderr).toContain(
|
||||
'Failed to connect to server http://127.0.0.1:2283/api: Error: 401'
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should login', async () => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
const key = await apiUtils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli([
|
||||
'login-key',
|
||||
app,
|
||||
`${key.secret}`,
|
||||
]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in...',
|
||||
'Logged in as admin@immich.cloud',
|
||||
'Wrote auth info to /tmp/immich/auth.yml',
|
||||
]);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const stats = await stat('/tmp/immich/auth.yml');
|
||||
const mode = (stats.mode & 0o777).toString(8);
|
||||
expect(mode).toEqual('600');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich server-info`, () => {
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
await cliUtils.login();
|
||||
});
|
||||
|
||||
it('should return the server info', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
expect.stringContaining('Server Version:'),
|
||||
expect.stringContaining('Image Types:'),
|
||||
expect.stringContaining('Video Types:'),
|
||||
'Statistics:',
|
||||
' Images: 0',
|
||||
' Videos: 0',
|
||||
' Total: 0',
|
||||
]);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import {
|
||||
apiUtils,
|
||||
asKeyAuth,
|
||||
cliUtils,
|
||||
dbUtils,
|
||||
immichCli,
|
||||
testAssetDir,
|
||||
} from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdir, readdir, rm, symlink } from 'fs/promises';
|
||||
|
||||
describe(`immich upload`, () => {
|
||||
let key: string;
|
||||
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await dbUtils.reset();
|
||||
key = await cliUtils.login();
|
||||
});
|
||||
|
||||
describe('immich upload --recursive', () => {
|
||||
it('should upload a folder recursively', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
]);
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
])
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --recursive --album', () => {
|
||||
it('should create albums from folder names', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
'--album',
|
||||
]);
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(9);
|
||||
|
||||
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
|
||||
expect(albums.length).toBe(1);
|
||||
expect(albums[0].albumName).toBe('nature');
|
||||
});
|
||||
|
||||
it('should add existing assets to albums', async () => {
|
||||
const response1 = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
]);
|
||||
expect(response1.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
])
|
||||
);
|
||||
expect(response1.stderr).toBe('');
|
||||
expect(response1.exitCode).toBe(0);
|
||||
|
||||
const assets1 = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets1.length).toBe(9);
|
||||
|
||||
const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
|
||||
expect(albums1.length).toBe(0);
|
||||
|
||||
const response2 = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
'--album',
|
||||
]);
|
||||
expect(response2.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'All assets were already uploaded, nothing to do.'
|
||||
),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
);
|
||||
expect(response2.stderr).toBe('');
|
||||
expect(response2.exitCode).toBe(0);
|
||||
|
||||
const assets2 = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets2.length).toBe(9);
|
||||
|
||||
const albums2 = await getAllAlbums({}, { headers: asKeyAuth(key) });
|
||||
expect(albums2.length).toBe(1);
|
||||
expect(albums2[0].albumName).toBe('nature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --recursive --album-name=e2e', () => {
|
||||
it('should create a named album', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`${testAssetDir}/albums/nature/`,
|
||||
'--recursive',
|
||||
'--album-name=e2e',
|
||||
]);
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Successfully created 1 new album'),
|
||||
expect.stringContaining('Successfully updated 9 assets'),
|
||||
])
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(9);
|
||||
|
||||
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
|
||||
expect(albums.length).toBe(1);
|
||||
expect(albums[0].albumName).toBe('e2e');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --delete', () => {
|
||||
it('should delete local files if specified', async () => {
|
||||
await mkdir(`/tmp/albums/nature`, { recursive: true });
|
||||
const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
|
||||
for (const file of filesToLink) {
|
||||
await symlink(
|
||||
`${testAssetDir}/albums/nature/${file}`,
|
||||
`/tmp/albums/nature/${file}`
|
||||
);
|
||||
}
|
||||
|
||||
const { stderr, stdout, exitCode } = await immichCli([
|
||||
'upload',
|
||||
`/tmp/albums/nature`,
|
||||
'--delete',
|
||||
]);
|
||||
|
||||
const files = await readdir(`/tmp/albums/nature`);
|
||||
await rm(`/tmp/albums/nature`, { recursive: true });
|
||||
expect(files).toEqual([]);
|
||||
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Successfully uploaded 9 assets'),
|
||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||
])
|
||||
);
|
||||
expect(stderr).toBe('');
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { apiUtils, immichCli } from 'src/utils';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8'));
|
||||
|
||||
describe(`immich --version`, () => {
|
||||
beforeAll(() => {
|
||||
apiUtils.setup();
|
||||
});
|
||||
|
||||
describe('immich --version', () => {
|
||||
it('should print the cli version', async () => {
|
||||
const { stdout, stderr, exitCode } = await immichCli(['--version']);
|
||||
expect(stdout).toEqual(pkg.version);
|
||||
expect(stderr).toEqual('');
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich -V', () => {
|
||||
it('should print the cli version', async () => {
|
||||
const { stdout, stderr, exitCode } = await immichCli(['-V']);
|
||||
expect(stdout).toEqual(pkg.version);
|
||||
expect(stderr).toEqual('');
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { UserAvatarColor } from '@immich/sdk';
|
||||
|
||||
export const uuidDto = {
|
||||
invalid: 'invalid-uuid',
|
||||
// valid uuid v4
|
||||
notFound: '00000000-0000-4000-a000-000000000000',
|
||||
};
|
||||
|
||||
const adminLoginDto = {
|
||||
email: 'admin@immich.cloud',
|
||||
password: 'password',
|
||||
};
|
||||
const adminSignupDto = { ...adminLoginDto, name: 'Immich Admin' };
|
||||
|
||||
export const loginDto = {
|
||||
admin: adminLoginDto,
|
||||
};
|
||||
|
||||
export const signupDto = {
|
||||
admin: adminSignupDto,
|
||||
};
|
||||
|
||||
export const createUserDto = {
|
||||
user1: {
|
||||
email: 'user1@immich.cloud',
|
||||
name: 'User 1',
|
||||
password: 'password1',
|
||||
},
|
||||
user2: {
|
||||
email: 'user2@immich.cloud',
|
||||
name: 'User 2',
|
||||
password: 'password12',
|
||||
},
|
||||
user3: {
|
||||
email: 'user3@immich.cloud',
|
||||
name: 'User 3',
|
||||
password: 'password123',
|
||||
},
|
||||
};
|
||||
|
||||
export const userDto = {
|
||||
admin: {
|
||||
name: signupDto.admin.name,
|
||||
email: signupDto.admin.email,
|
||||
password: signupDto.admin.password,
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
tags: [],
|
||||
assets: [],
|
||||
memoriesEnabled: true,
|
||||
avatarColor: UserAvatarColor.Primary,
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
user1: {
|
||||
name: createUserDto.user1.name,
|
||||
email: createUserDto.user1.email,
|
||||
password: createUserDto.user1.password,
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
tags: [],
|
||||
assets: [],
|
||||
memoriesEnabled: true,
|
||||
avatarColor: UserAvatarColor.Primary,
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
},
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const errorDto = {
|
||||
unauthorized: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
},
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
},
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
},
|
||||
noDeleteUploadLibrary: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Cannot delete the last upload library',
|
||||
},
|
||||
};
|
||||
|
||||
export const signupResponseDto = {
|
||||
admin: {
|
||||
avatarColor: expect.any(String),
|
||||
id: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
email: 'admin@immich.cloud',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
profileImagePath: '',
|
||||
// why? lol
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
memoriesEnabled: true,
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const loginResponseDto = {
|
||||
admin: {
|
||||
accessToken: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
isAdmin: true,
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: true,
|
||||
userEmail: 'admin@immich.cloud',
|
||||
userId: expect.any(String),
|
||||
},
|
||||
};
|
||||
export const deviceDto = {
|
||||
current: {
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
current: true,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
},
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { spawn, exec } from 'child_process';
|
||||
|
||||
export default async () => {
|
||||
let _resolve: () => unknown;
|
||||
const promise = new Promise<void>((resolve) => (_resolve = resolve));
|
||||
|
||||
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const input = data.toString();
|
||||
console.log(input);
|
||||
if (input.includes('Immich Server is listening')) {
|
||||
_resolve();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => console.log(data.toString()));
|
||||
|
||||
await promise;
|
||||
|
||||
return async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
exec('docker compose down', () => resolve())
|
||||
);
|
||||
};
|
||||
};
|
||||
213
e2e/src/utils.ts
213
e2e/src/utils.ts
@@ -1,213 +0,0 @@
|
||||
import {
|
||||
AssetResponseDto,
|
||||
CreateAssetDto,
|
||||
CreateUserDto,
|
||||
LoginResponseDto,
|
||||
createApiKey,
|
||||
createUser,
|
||||
defaults,
|
||||
login,
|
||||
setAdminOnboarding,
|
||||
signUpAdmin,
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
import request from 'supertest';
|
||||
|
||||
export const app = 'http://127.0.0.1:2283/api';
|
||||
|
||||
const directoryExists = (directory: string) =>
|
||||
access(directory)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
// TODO move test assets into e2e/assets
|
||||
export const testAssetDir = path.resolve(`./../server/test/assets/`);
|
||||
|
||||
if (!(await directoryExists(`${testAssetDir}/albums`))) {
|
||||
throw new Error(
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
|
||||
);
|
||||
}
|
||||
|
||||
const setBaseUrl = () => (defaults.baseUrl = app);
|
||||
export const asBearerAuth = (accessToken: string) => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
|
||||
let client: pg.Client | null = null;
|
||||
|
||||
export const dbUtils = {
|
||||
reset: async () => {
|
||||
try {
|
||||
if (!client) {
|
||||
client = new pg.Client(
|
||||
'postgres://postgres:postgres@127.0.0.1:5433/immich'
|
||||
);
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
for (const table of [
|
||||
'albums',
|
||||
'assets',
|
||||
'api_keys',
|
||||
'user_token',
|
||||
'users',
|
||||
'system_metadata',
|
||||
]) {
|
||||
await client.query(`DELETE FROM ${table} CASCADE;`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
teardown: async () => {
|
||||
try {
|
||||
if (client) {
|
||||
await client.end();
|
||||
client = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to teardown database', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
export interface CliResponse {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export const immichCli = async (args: string[]) => {
|
||||
let _resolve: (value: CliResponse) => void;
|
||||
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
|
||||
const _args = ['node_modules/.bin/immich', '-d', '/tmp/immich/', ...args];
|
||||
const child = spawn('node', _args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => (stdout += data.toString()));
|
||||
child.stderr.on('data', (data) => (stderr += data.toString()));
|
||||
child.on('exit', (exitCode) => {
|
||||
_resolve({
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode,
|
||||
});
|
||||
});
|
||||
|
||||
return deferred;
|
||||
};
|
||||
|
||||
export interface AdminSetupOptions {
|
||||
onboarding?: boolean;
|
||||
}
|
||||
|
||||
export const apiUtils = {
|
||||
setup: () => {
|
||||
setBaseUrl();
|
||||
},
|
||||
adminSetup: async (options?: AdminSetupOptions) => {
|
||||
options = options || { onboarding: true };
|
||||
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
const response = await login({ loginCredentialDto: loginDto.admin });
|
||||
if (options.onboarding) {
|
||||
await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) });
|
||||
}
|
||||
return response;
|
||||
},
|
||||
userSetup: async (accessToken: string, dto: CreateUserDto) => {
|
||||
await createUser(
|
||||
{ createUserDto: dto },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
);
|
||||
return login({
|
||||
loginCredentialDto: { email: dto.email, password: dto.password },
|
||||
});
|
||||
},
|
||||
createApiKey: (accessToken: string) => {
|
||||
return createApiKey(
|
||||
{ apiKeyCreateDto: { name: 'e2e' } },
|
||||
{ headers: asBearerAuth(accessToken) }
|
||||
);
|
||||
},
|
||||
createAsset: async (
|
||||
accessToken: string,
|
||||
dto?: Omit<CreateAssetDto, 'assetData'>
|
||||
) => {
|
||||
dto = dto || {
|
||||
deviceAssetId: 'test-1',
|
||||
deviceId: 'test',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
};
|
||||
const { body } = await request(app)
|
||||
.post(`/asset/upload`)
|
||||
.field('deviceAssetId', dto.deviceAssetId)
|
||||
.field('deviceId', dto.deviceId)
|
||||
.field('fileCreatedAt', dto.fileCreatedAt)
|
||||
.field('fileModifiedAt', dto.fileModifiedAt)
|
||||
.attach('assetData', randomBytes(32), 'example.jpg')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
return body as AssetResponseDto;
|
||||
},
|
||||
};
|
||||
|
||||
export const cliUtils = {
|
||||
login: async () => {
|
||||
const admin = await apiUtils.adminSetup();
|
||||
const key = await apiUtils.createApiKey(admin.accessToken);
|
||||
await immichCli(['login-key', app, `${key.secret}`]);
|
||||
return key.secret;
|
||||
},
|
||||
};
|
||||
|
||||
export const webUtils = {
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string) =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_access_token',
|
||||
value: accessToken,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'immich_auth_type',
|
||||
value: 'password',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]),
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
AssetResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkType,
|
||||
createAlbum,
|
||||
createSharedLink,
|
||||
} from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { apiUtils, asBearerAuth, dbUtils } from 'src/utils';
|
||||
|
||||
test.describe('Shared Links', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetResponseDto;
|
||||
let album: AlbumResponseDto;
|
||||
let sharedLink: SharedLinkResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup();
|
||||
asset = await apiUtils.createAsset(admin.accessToken);
|
||||
album = await createAlbum(
|
||||
{
|
||||
createAlbumDto: {
|
||||
albumName: 'Test Album',
|
||||
assetIds: [asset.id],
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
// { headers: asBearerAuth(admin.accessToken)},
|
||||
);
|
||||
sharedLink = await createSharedLink(
|
||||
{
|
||||
sharedLinkCreateDto: {
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) }
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await dbUtils.teardown();
|
||||
});
|
||||
|
||||
test('download from a shared link', async ({ page }) => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.locator('.group > div').first().hover();
|
||||
await page.waitForSelector('#asset-group-by-date svg');
|
||||
await page.getByRole('checkbox').click();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING').waitFor();
|
||||
});
|
||||
});
|
||||
88
e2e/test-utils.ts
Normal file
88
e2e/test-utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import pg from 'pg';
|
||||
import { defaults, login, setAdminOnboarding, signUpAdmin } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
const client = new pg.Client(
|
||||
'postgres://postgres:postgres@localhost:5432/immich'
|
||||
);
|
||||
let connected = false;
|
||||
|
||||
const loginCredentialDto = {
|
||||
email: 'admin@immich.cloud',
|
||||
password: 'password',
|
||||
};
|
||||
const signUpDto = { ...loginCredentialDto, name: 'Immich Admin' };
|
||||
|
||||
const setBaseUrl = () => (defaults.baseUrl = 'http://127.0.0.1:2283/api');
|
||||
const asAuthHeader = (accessToken: string) => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
|
||||
export const app = {
|
||||
adminSetup: async (context: BrowserContext) => {
|
||||
setBaseUrl();
|
||||
await signUpAdmin({ signUpDto });
|
||||
|
||||
const response = await login({ loginCredentialDto });
|
||||
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_access_token',
|
||||
value: response.accessToken,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'immich_auth_type',
|
||||
value: 'password',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
expires: 1742402728,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
|
||||
await setAdminOnboarding({ headers: asAuthHeader(response.accessToken) });
|
||||
|
||||
return response;
|
||||
},
|
||||
reset: async () => {
|
||||
try {
|
||||
if (!connected) {
|
||||
await client.connect();
|
||||
connected = true;
|
||||
}
|
||||
|
||||
for (const table of ['user_token', 'users', 'system_metadata']) {
|
||||
await client.query(`DELETE FROM ${table} CASCADE;`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database', error);
|
||||
}
|
||||
},
|
||||
teardown: async () => {
|
||||
try {
|
||||
if (connected) {
|
||||
await client.end();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to teardown database', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,8 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"rootDirs": ["src"],
|
||||
"baseUrl": "./"
|
||||
"baseUrl": "./",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
|
||||
globalSetup: ['src/setup.ts'],
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
16
install.sh
16
install.sh
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "Starting Immich installation..."
|
||||
|
||||
ip_address=$(hostname -I | awk '{print $1}')
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\032[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
create_immich_directory() {
|
||||
echo "Creating Immich directory..."
|
||||
mkdir -p ./immich-app/immich-data
|
||||
cd ./immich-app || exit
|
||||
cd ./immich-app
|
||||
}
|
||||
|
||||
download_docker_compose_file() {
|
||||
@@ -32,18 +34,18 @@ replace_env_value() {
|
||||
populate_upload_location() {
|
||||
echo "Populating default UPLOAD_LOCATION value..."
|
||||
upload_location=$(pwd)/immich-data
|
||||
replace_env_value "UPLOAD_LOCATION" "$upload_location"
|
||||
replace_env_value "UPLOAD_LOCATION" $upload_location
|
||||
}
|
||||
|
||||
start_docker_compose() {
|
||||
echo "Starting Immich's docker containers"
|
||||
|
||||
if docker compose >/dev/null 2>&1; then
|
||||
if docker compose > /dev/null 2>&1; then
|
||||
docker_bin="docker compose"
|
||||
elif docker-compose >/dev/null 2>&1; then
|
||||
elif docker-compose > /dev/null 2>&1; then
|
||||
docker_bin="docker-compose"
|
||||
else
|
||||
echo "Cannot find \`docker compose\` or \`docker-compose\`."
|
||||
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I"$ARMNN_PATH"/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L"$ARMNN_PATH" ann.cpp
|
||||
g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I$ARMNN_PATH/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L$ARMNN_PATH ann.cpp
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
cd armnn-23.11/ || exit
|
||||
cd armnn-23.11/
|
||||
g++ -o ../armnnconverter -O1 -DARMNN_ONNX_PARSER -DARMNN_SERIALIZER -DARMNN_TF_LITE_PARSER -fuse-ld=gold -std=c++17 -Iinclude -Isrc/armnnUtils -Ithird-party -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -larmnnSerializer -L../armnn src/armnnConverter/ArmnnConverter.cpp
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.95.0"
|
||||
version = "1.94.1"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
export LD_PRELOAD="$lib_path"
|
||||
export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
export LD_BIND_NOW=1
|
||||
|
||||
: "${MACHINE_LEARNING_HOST:=0.0.0.0}"
|
||||
@@ -11,8 +10,8 @@ export LD_BIND_NOW=1
|
||||
|
||||
gunicorn app.main:app \
|
||||
-k app.config.CustomUvicornWorker \
|
||||
-w "$MACHINE_LEARNING_WORKERS" \
|
||||
-b "$MACHINE_LEARNING_HOST":"$MACHINE_LEARNING_PORT" \
|
||||
-t "$MACHINE_LEARNING_WORKER_TIMEOUT" \
|
||||
-w $MACHINE_LEARNING_WORKERS \
|
||||
-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
|
||||
-t $MACHINE_LEARNING_WORKER_TIMEOUT \
|
||||
--log-config-json log_conf.json \
|
||||
--graceful-timeout 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#/bin/bash
|
||||
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
@@ -25,10 +25,10 @@ while getopts 's:m:' flag; do
|
||||
esac
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' server/package.json)
|
||||
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
|
||||
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
|
||||
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
|
||||
CURRENT_SERVER=$(cat server/package.json | jq -r '.version')
|
||||
MAJOR=$(echo $CURRENT_SERVER | cut -d '.' -f1)
|
||||
MINOR=$(echo $CURRENT_SERVER | cut -d '.' -f2)
|
||||
PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
@@ -48,7 +48,7 @@ fi
|
||||
|
||||
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
|
||||
|
||||
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
|
||||
CURRENT_MOBILE=$(cat mobile/pubspec.yaml | grep "^version: .*+[0-9]\+$" | cut -d "+" -f2)
|
||||
NEXT_MOBILE=$CURRENT_MOBILE
|
||||
if [[ $MOBILE_PUMP == "true" ]]; then
|
||||
set $((NEXT_MOBILE++))
|
||||
@@ -61,10 +61,9 @@ fi
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
npm --prefix server version "$SERVER_PUMP"
|
||||
npm --prefix web version "$SERVER_PUMP"
|
||||
npm --prefix server version $SERVER_PUMP
|
||||
make open-api
|
||||
poetry --directory machine-learning version "$SERVER_PUMP"
|
||||
poetry --directory machine-learning version $SERVER_PUMP
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
@@ -76,4 +75,4 @@ sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/
|
||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 122,
|
||||
"android.injected.version.name" => "1.95.0",
|
||||
"android.injected.version.code" => 121,
|
||||
"android.injected.version.name" => "1.94.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# The default execution directory of this script is the ci_scripts directory.
|
||||
cd "$CI_WORKSPACE"/mobile || exit
|
||||
cd $CI_WORKSPACE/mobile
|
||||
|
||||
# Install Flutter using git.
|
||||
git clone https://github.com/flutter/flutter.git --depth 1 -b stable "$HOME"/flutter
|
||||
git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter
|
||||
export PATH="$PATH:$HOME/flutter/bin"
|
||||
|
||||
# Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
|
||||
@@ -14,7 +14,7 @@ flutter precache --ios
|
||||
flutter pub get
|
||||
|
||||
# Install CocoaPods using Homebrew.
|
||||
export HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
|
||||
brew install cocoapods
|
||||
|
||||
# Install CocoaPods dependencies.
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.95.0"
|
||||
version_number: "1.94.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -290,7 +290,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
final now = Timeline.now;
|
||||
if (now > (_hapticFeedbackTS + feedbackInterval)) {
|
||||
_hapticFeedbackTS = now;
|
||||
HapticFeedback.mediumImpact();
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class MemoryProgressIndicator extends StatelessWidget {
|
||||
/// The number of ticks in the progress indicator
|
||||
@@ -38,9 +39,9 @@ class MemoryProgressIndicator extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
border: i == 0
|
||||
? null
|
||||
: const Border(
|
||||
: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.black,
|
||||
color: context.colorScheme.onSecondaryContainer,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -110,8 +110,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
|
||||
final headers = {"x-immich-user-token": accessToken};
|
||||
if (endpoint.userInfo.isNotEmpty) {
|
||||
headers["Authorization"] =
|
||||
"Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
|
||||
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
|
||||
}
|
||||
|
||||
debugPrint("Attempting to connect to websocket");
|
||||
@@ -162,7 +161,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('on_asset_trash', _handleServerUpdates);
|
||||
socket.on('on_asset_restore', _handleServerUpdates);
|
||||
socket.on('on_asset_update', _handleServerUpdates);
|
||||
socket.on('on_asset_stack_update', _handleServerUpdates);
|
||||
socket.on('on_asset_hidden', _handleOnAssetHidden);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
} catch (e) {
|
||||
|
||||
@@ -63,7 +63,7 @@ class AssetService {
|
||||
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> _getRemoteAssets(User user) async {
|
||||
const int chunkSize = 10000;
|
||||
const int chunkSize = 5000;
|
||||
try {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final List<Asset> allAssets = [];
|
||||
|
||||
@@ -27,12 +27,7 @@ class ShareService {
|
||||
final downloadedXFiles = <XFile>[];
|
||||
|
||||
for (var asset in assets) {
|
||||
if (asset.isLocal) {
|
||||
// Prefer local assets to share
|
||||
File? f = await asset.local!.file;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
} else if (asset.isRemote) {
|
||||
// Download remote asset otherwise
|
||||
if (asset.isRemote) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = asset.fileName;
|
||||
final tempFile = await File('${tempDir.path}/$fileName').create();
|
||||
@@ -48,6 +43,9 @@ class ShareService {
|
||||
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
downloadedXFiles.add(XFile(tempFile.path));
|
||||
} else {
|
||||
File? f = await asset.local!.file;
|
||||
downloadedXFiles.add(XFile(f!.path));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class ImmichImage extends StatelessWidget {
|
||||
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
fadeOutDuration: const Duration(milliseconds: 400),
|
||||
placeholderBuilder: (context) {
|
||||
if (placeholder != null) {
|
||||
// Use the gray box placeholder
|
||||
|
||||
9
mobile/openapi/.openapi-generator/FILES
generated
9
mobile/openapi/.openapi-generator/FILES
generated
@@ -182,9 +182,6 @@ doc/UserAvatarColor.md
|
||||
doc/UserDto.md
|
||||
doc/UserResponseDto.md
|
||||
doc/ValidateAccessTokenResponseDto.md
|
||||
doc/ValidateLibraryDto.md
|
||||
doc/ValidateLibraryImportPathResponseDto.md
|
||||
doc/ValidateLibraryResponseDto.md
|
||||
doc/VideoCodec.md
|
||||
git_push.sh
|
||||
lib/api.dart
|
||||
@@ -375,9 +372,6 @@ lib/model/user_avatar_color.dart
|
||||
lib/model/user_dto.dart
|
||||
lib/model/user_response_dto.dart
|
||||
lib/model/validate_access_token_response_dto.dart
|
||||
lib/model/validate_library_dto.dart
|
||||
lib/model/validate_library_import_path_response_dto.dart
|
||||
lib/model/validate_library_response_dto.dart
|
||||
lib/model/video_codec.dart
|
||||
pubspec.yaml
|
||||
test/activity_api_test.dart
|
||||
@@ -559,7 +553,4 @@ test/user_avatar_color_test.dart
|
||||
test/user_dto_test.dart
|
||||
test/user_response_dto_test.dart
|
||||
test/validate_access_token_response_dto_test.dart
|
||||
test/validate_library_dto_test.dart
|
||||
test/validate_library_import_path_response_dto_test.dart
|
||||
test/validate_library_response_dto_test.dart
|
||||
test/video_codec_test.dart
|
||||
|
||||
6
mobile/openapi/README.md
generated
6
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.95.0
|
||||
- API version: 1.94.1
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
@@ -141,7 +141,6 @@ Class | Method | HTTP request | Description
|
||||
*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline |
|
||||
*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan |
|
||||
*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /library/{id} |
|
||||
*LibraryApi* | [**validate**](doc//LibraryApi.md#validate) | **POST** /library/{id}/validate |
|
||||
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
|
||||
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
|
||||
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
|
||||
@@ -373,9 +372,6 @@ Class | Method | HTTP request | Description
|
||||
- [UserDto](doc//UserDto.md)
|
||||
- [UserResponseDto](doc//UserResponseDto.md)
|
||||
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
|
||||
- [ValidateLibraryDto](doc//ValidateLibraryDto.md)
|
||||
- [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
|
||||
- [ValidateLibraryResponseDto](doc//ValidateLibraryResponseDto.md)
|
||||
- [VideoCodec](doc//VideoCodec.md)
|
||||
|
||||
|
||||
|
||||
58
mobile/openapi/doc/LibraryApi.md
generated
58
mobile/openapi/doc/LibraryApi.md
generated
@@ -17,7 +17,6 @@ Method | HTTP request | Description
|
||||
[**removeOfflineFiles**](LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline |
|
||||
[**scanLibrary**](LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan |
|
||||
[**updateLibrary**](LibraryApi.md#updatelibrary) | **PUT** /library/{id} |
|
||||
[**validate**](LibraryApi.md#validate) | **POST** /library/{id}/validate |
|
||||
|
||||
|
||||
# **createLibrary**
|
||||
@@ -457,60 +456,3 @@ Name | Type | Description | Notes
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **validate**
|
||||
> ValidateLibraryResponseDto validate(id, validateLibraryDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = LibraryApi();
|
||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final validateLibraryDto = ValidateLibraryDto(); // ValidateLibraryDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.validate(id, validateLibraryDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling LibraryApi->validate: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**id** | **String**| |
|
||||
**validateLibraryDto** | [**ValidateLibraryDto**](ValidateLibraryDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**ValidateLibraryResponseDto**](ValidateLibraryResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
16
mobile/openapi/doc/ValidateLibraryDto.md
generated
16
mobile/openapi/doc/ValidateLibraryDto.md
generated
@@ -1,16 +0,0 @@
|
||||
# openapi.model.ValidateLibraryDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**exclusionPatterns** | **List<String>** | | [optional] [default to const []]
|
||||
**importPaths** | **List<String>** | | [optional] [default to const []]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# openapi.model.ValidateLibraryImportPathResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**importPath** | **String** | |
|
||||
**isValid** | **bool** | | [optional] [default to false]
|
||||
**message** | **String** | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
15
mobile/openapi/doc/ValidateLibraryResponseDto.md
generated
15
mobile/openapi/doc/ValidateLibraryResponseDto.md
generated
@@ -1,15 +0,0 @@
|
||||
# openapi.model.ValidateLibraryResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**importPaths** | [**List<ValidateLibraryImportPathResponseDto>**](ValidateLibraryImportPathResponseDto.md) | | [optional] [default to const []]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -209,9 +209,6 @@ part 'model/user_avatar_color.dart';
|
||||
part 'model/user_dto.dart';
|
||||
part 'model/user_response_dto.dart';
|
||||
part 'model/validate_access_token_response_dto.dart';
|
||||
part 'model/validate_library_dto.dart';
|
||||
part 'model/validate_library_import_path_response_dto.dart';
|
||||
part 'model/validate_library_response_dto.dart';
|
||||
part 'model/video_codec.dart';
|
||||
|
||||
|
||||
|
||||
52
mobile/openapi/lib/api/library_api.dart
generated
52
mobile/openapi/lib/api/library_api.dart
generated
@@ -378,56 +378,4 @@ class LibraryApi {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /library/{id}/validate' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ValidateLibraryDto] validateLibraryDto (required):
|
||||
Future<Response> validateWithHttpInfo(String id, ValidateLibraryDto validateLibraryDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/library/{id}/validate'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = validateLibraryDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ValidateLibraryDto] validateLibraryDto (required):
|
||||
Future<ValidateLibraryResponseDto?> validate(String id, ValidateLibraryDto validateLibraryDto,) async {
|
||||
final response = await validateWithHttpInfo(id, validateLibraryDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ValidateLibraryResponseDto',) as ValidateLibraryResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@@ -500,12 +500,6 @@ class ApiClient {
|
||||
return UserResponseDto.fromJson(value);
|
||||
case 'ValidateAccessTokenResponseDto':
|
||||
return ValidateAccessTokenResponseDto.fromJson(value);
|
||||
case 'ValidateLibraryDto':
|
||||
return ValidateLibraryDto.fromJson(value);
|
||||
case 'ValidateLibraryImportPathResponseDto':
|
||||
return ValidateLibraryImportPathResponseDto.fromJson(value);
|
||||
case 'ValidateLibraryResponseDto':
|
||||
return ValidateLibraryResponseDto.fromJson(value);
|
||||
case 'VideoCodec':
|
||||
return VideoCodecTypeTransformer().decode(value);
|
||||
default:
|
||||
|
||||
108
mobile/openapi/lib/model/validate_library_dto.dart
generated
108
mobile/openapi/lib/model/validate_library_dto.dart
generated
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class ValidateLibraryDto {
|
||||
/// Returns a new [ValidateLibraryDto] instance.
|
||||
ValidateLibraryDto({
|
||||
this.exclusionPatterns = const [],
|
||||
this.importPaths = const [],
|
||||
});
|
||||
|
||||
List<String> exclusionPatterns;
|
||||
|
||||
List<String> importPaths;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryDto &&
|
||||
_deepEquality.equals(other.exclusionPatterns, exclusionPatterns) &&
|
||||
_deepEquality.equals(other.importPaths, importPaths);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(exclusionPatterns.hashCode) +
|
||||
(importPaths.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'exclusionPatterns'] = this.exclusionPatterns;
|
||||
json[r'importPaths'] = this.importPaths;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ValidateLibraryDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ValidateLibraryDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ValidateLibraryDto(
|
||||
exclusionPatterns: json[r'exclusionPatterns'] is Iterable
|
||||
? (json[r'exclusionPatterns'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
importPaths: json[r'importPaths'] is Iterable
|
||||
? (json[r'importPaths'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ValidateLibraryDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ValidateLibraryDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ValidateLibraryDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ValidateLibraryDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ValidateLibraryDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ValidateLibraryDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ValidateLibraryDto-objects as value to a dart map
|
||||
static Map<String, List<ValidateLibraryDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ValidateLibraryDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ValidateLibraryDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class ValidateLibraryImportPathResponseDto {
|
||||
/// Returns a new [ValidateLibraryImportPathResponseDto] instance.
|
||||
ValidateLibraryImportPathResponseDto({
|
||||
required this.importPath,
|
||||
this.isValid = false,
|
||||
this.message,
|
||||
});
|
||||
|
||||
String importPath;
|
||||
|
||||
bool isValid;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? message;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryImportPathResponseDto &&
|
||||
other.importPath == importPath &&
|
||||
other.isValid == isValid &&
|
||||
other.message == message;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(importPath.hashCode) +
|
||||
(isValid.hashCode) +
|
||||
(message == null ? 0 : message!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidateLibraryImportPathResponseDto[importPath=$importPath, isValid=$isValid, message=$message]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'importPath'] = this.importPath;
|
||||
json[r'isValid'] = this.isValid;
|
||||
if (this.message != null) {
|
||||
json[r'message'] = this.message;
|
||||
} else {
|
||||
// json[r'message'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ValidateLibraryImportPathResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ValidateLibraryImportPathResponseDto(
|
||||
importPath: mapValueOfType<String>(json, r'importPath')!,
|
||||
isValid: mapValueOfType<bool>(json, r'isValid') ?? false,
|
||||
message: mapValueOfType<String>(json, r'message'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ValidateLibraryImportPathResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ValidateLibraryImportPathResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ValidateLibraryImportPathResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ValidateLibraryImportPathResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ValidateLibraryImportPathResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ValidateLibraryImportPathResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ValidateLibraryImportPathResponseDto-objects as value to a dart map
|
||||
static Map<String, List<ValidateLibraryImportPathResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ValidateLibraryImportPathResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ValidateLibraryImportPathResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'importPath',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class ValidateLibraryResponseDto {
|
||||
/// Returns a new [ValidateLibraryResponseDto] instance.
|
||||
ValidateLibraryResponseDto({
|
||||
this.importPaths = const [],
|
||||
});
|
||||
|
||||
List<ValidateLibraryImportPathResponseDto> importPaths;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryResponseDto &&
|
||||
_deepEquality.equals(other.importPaths, importPaths);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(importPaths.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidateLibraryResponseDto[importPaths=$importPaths]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'importPaths'] = this.importPaths;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ValidateLibraryResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ValidateLibraryResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ValidateLibraryResponseDto(
|
||||
importPaths: ValidateLibraryImportPathResponseDto.listFromJson(json[r'importPaths']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ValidateLibraryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ValidateLibraryResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ValidateLibraryResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ValidateLibraryResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, ValidateLibraryResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ValidateLibraryResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ValidateLibraryResponseDto-objects as value to a dart map
|
||||
static Map<String, List<ValidateLibraryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ValidateLibraryResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ValidateLibraryResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
5
mobile/openapi/test/library_api_test.dart
generated
5
mobile/openapi/test/library_api_test.dart
generated
@@ -57,10 +57,5 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<ValidateLibraryResponseDto> validate(String id, ValidateLibraryDto validateLibraryDto) async
|
||||
test('test validate', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
32
mobile/openapi/test/validate_library_dto_test.dart
generated
32
mobile/openapi/test/validate_library_dto_test.dart
generated
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for ValidateLibraryDto
|
||||
void main() {
|
||||
// final instance = ValidateLibraryDto();
|
||||
|
||||
group('test ValidateLibraryDto', () {
|
||||
// List<String> exclusionPatterns (default value: const [])
|
||||
test('to test the property `exclusionPatterns`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> importPaths (default value: const [])
|
||||
test('to test the property `importPaths`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for ValidateLibraryImportPathResponseDto
|
||||
void main() {
|
||||
// final instance = ValidateLibraryImportPathResponseDto();
|
||||
|
||||
group('test ValidateLibraryImportPathResponseDto', () {
|
||||
// String importPath
|
||||
test('to test the property `importPath`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool isValid (default value: false)
|
||||
test('to test the property `isValid`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String message
|
||||
test('to test the property `message`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for ValidateLibraryResponseDto
|
||||
void main() {
|
||||
// final instance = ValidateLibraryResponseDto();
|
||||
|
||||
group('test ValidateLibraryResponseDto', () {
|
||||
// List<ValidateLibraryImportPathResponseDto> importPaths (default value: const [])
|
||||
test('to test the property `importPaths`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.95.0+122
|
||||
version: 1.94.1+121
|
||||
isar_version: &isar_version 3.1.0+1
|
||||
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
cd .isar || exit
|
||||
cd .isar
|
||||
bash tool/build_android.sh x86
|
||||
bash tool/build_android.sh x64
|
||||
bash tool/build_android.sh armv7
|
||||
|
||||
@@ -3618,58 +3618,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/library/{id}/validate": {
|
||||
"post": {
|
||||
"operationId": "validate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidateLibraryDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidateLibraryResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Library"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/oauth/authorize": {
|
||||
"post": {
|
||||
"operationId": "startOAuth",
|
||||
@@ -6413,7 +6361,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.95.0",
|
||||
"version": "1.94.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -10458,52 +10406,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ValidateLibraryDto": {
|
||||
"properties": {
|
||||
"exclusionPatterns": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"importPaths": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ValidateLibraryImportPathResponseDto": {
|
||||
"properties": {
|
||||
"importPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"isValid": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"importPath"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ValidateLibraryResponseDto": {
|
||||
"properties": {
|
||||
"importPaths": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidateLibraryImportPathResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"VideoCodec": {
|
||||
"enum": [
|
||||
"h264",
|
||||
|
||||
161
open-api/typescript-sdk/axios-client/api.ts
generated
161
open-api/typescript-sdk/axios-client/api.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.95.0
|
||||
* The version of the OpenAPI document: 1.94.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
@@ -5316,63 +5316,6 @@ export interface ValidateAccessTokenResponseDto {
|
||||
*/
|
||||
'authStatus': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ValidateLibraryDto
|
||||
*/
|
||||
export interface ValidateLibraryDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof ValidateLibraryDto
|
||||
*/
|
||||
'exclusionPatterns'?: Array<string>;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof ValidateLibraryDto
|
||||
*/
|
||||
'importPaths'?: Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ValidateLibraryImportPathResponseDto
|
||||
*/
|
||||
export interface ValidateLibraryImportPathResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ValidateLibraryImportPathResponseDto
|
||||
*/
|
||||
'importPath': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ValidateLibraryImportPathResponseDto
|
||||
*/
|
||||
'isValid'?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ValidateLibraryImportPathResponseDto
|
||||
*/
|
||||
'message'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ValidateLibraryResponseDto
|
||||
*/
|
||||
export interface ValidateLibraryResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<ValidateLibraryImportPathResponseDto>}
|
||||
* @memberof ValidateLibraryResponseDto
|
||||
*/
|
||||
'importPaths'?: Array<ValidateLibraryImportPathResponseDto>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -12870,54 +12813,6 @@ export const LibraryApiAxiosParamCreator = function (configuration?: Configurati
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(updateLibraryDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {ValidateLibraryDto} validateLibraryDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
validate: async (id: string, validateLibraryDto: ValidateLibraryDto, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('validate', 'id', id)
|
||||
// verify required parameter 'validateLibraryDto' is not null or undefined
|
||||
assertParamExists('validate', 'validateLibraryDto', validateLibraryDto)
|
||||
const localVarPath = `/library/{id}/validate`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(validateLibraryDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
@@ -13030,19 +12925,6 @@ export const LibraryApiFp = function(configuration?: Configuration) {
|
||||
const operationBasePath = operationServerMap['LibraryApi.updateLibrary']?.[index]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {ValidateLibraryDto} validateLibraryDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async validate(id: string, validateLibraryDto: ValidateLibraryDto, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ValidateLibraryResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.validate(id, validateLibraryDto, options);
|
||||
const index = configuration?.serverIndex ?? 0;
|
||||
const operationBasePath = operationServerMap['LibraryApi.validate']?.[index]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13124,15 +13006,6 @@ export const LibraryApiFactory = function (configuration?: Configuration, basePa
|
||||
updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: RawAxiosRequestConfig): AxiosPromise<LibraryResponseDto> {
|
||||
return localVarFp.updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {LibraryApiValidateRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
validate(requestParameters: LibraryApiValidateRequest, options?: RawAxiosRequestConfig): AxiosPromise<ValidateLibraryResponseDto> {
|
||||
return localVarFp.validate(requestParameters.id, requestParameters.validateLibraryDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13248,27 +13121,6 @@ export interface LibraryApiUpdateLibraryRequest {
|
||||
readonly updateLibraryDto: UpdateLibraryDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for validate operation in LibraryApi.
|
||||
* @export
|
||||
* @interface LibraryApiValidateRequest
|
||||
*/
|
||||
export interface LibraryApiValidateRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof LibraryApiValidate
|
||||
*/
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {ValidateLibraryDto}
|
||||
* @memberof LibraryApiValidate
|
||||
*/
|
||||
readonly validateLibraryDto: ValidateLibraryDto
|
||||
}
|
||||
|
||||
/**
|
||||
* LibraryApi - object-oriented interface
|
||||
* @export
|
||||
@@ -13362,17 +13214,6 @@ export class LibraryApi extends BaseAPI {
|
||||
public updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: RawAxiosRequestConfig) {
|
||||
return LibraryApiFp(this.configuration).updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {LibraryApiValidateRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof LibraryApi
|
||||
*/
|
||||
public validate(requestParameters: LibraryApiValidateRequest, options?: RawAxiosRequestConfig) {
|
||||
return LibraryApiFp(this.configuration).validate(requestParameters.id, requestParameters.validateLibraryDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
open-api/typescript-sdk/axios-client/base.ts
generated
2
open-api/typescript-sdk/axios-client/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.95.0
|
||||
* The version of the OpenAPI document: 1.94.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
open-api/typescript-sdk/axios-client/common.ts
generated
2
open-api/typescript-sdk/axios-client/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.95.0
|
||||
* The version of the OpenAPI document: 1.94.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.95.0
|
||||
* The version of the OpenAPI document: 1.94.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
open-api/typescript-sdk/axios-client/index.ts
generated
2
open-api/typescript-sdk/axios-client/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.95.0
|
||||
* The version of the OpenAPI document: 1.94.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
27
open-api/typescript-sdk/fetch-client.ts
generated
27
open-api/typescript-sdk/fetch-client.ts
generated
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.95.0
|
||||
* 1.94.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -480,18 +480,6 @@ export type LibraryStatsResponseDto = {
|
||||
usage: number;
|
||||
videos: number;
|
||||
};
|
||||
export type ValidateLibraryDto = {
|
||||
exclusionPatterns?: string[];
|
||||
importPaths?: string[];
|
||||
};
|
||||
export type ValidateLibraryImportPathResponseDto = {
|
||||
importPath: string;
|
||||
isValid?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
export type ValidateLibraryResponseDto = {
|
||||
importPaths?: ValidateLibraryImportPathResponseDto[];
|
||||
};
|
||||
export type OAuthConfigDto = {
|
||||
redirectUri: string;
|
||||
};
|
||||
@@ -1913,19 +1901,6 @@ export function getLibraryStatistics({ id }: {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function validate({ id, validateLibraryDto }: {
|
||||
id: string;
|
||||
validateLibraryDto: ValidateLibraryDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: ValidateLibraryResponseDto;
|
||||
}>(`/library/${encodeURIComponent(id)}/validate`, oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: validateLibraryDto
|
||||
})));
|
||||
}
|
||||
export function startOAuth({ oAuthConfigDto }: {
|
||||
oAuthConfigDto: OAuthConfigDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN npm prune --omit=dev --omit=optional
|
||||
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
|
||||
|
||||
# web build
|
||||
FROM node:iron-alpine3.18@sha256:a02826c7340c37a29179152723190bcc3044f933c925f3c2d78abb20f794de3f as web
|
||||
FROM node:iron-alpine3.18 as web
|
||||
|
||||
WORKDIR /usr/src/open-api/typescript-sdk
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
@@ -59,4 +59,4 @@ COPY LICENSE /LICENSE
|
||||
ENV PATH="${PATH}:/usr/src/app/bin"
|
||||
VOLUME /usr/src/app/upload
|
||||
EXPOSE 3001
|
||||
ENTRYPOINT ["tini", "--", "/bin/bash"]
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
node /usr/src/app/node_modules/.bin/immich "$@"
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
/usr/src/app/start.sh immich-admin "$1"
|
||||
/usr/src/app/start.sh immich-admin $1
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
node /usr/src/app/node_modules/.bin/jest --config e2e/"$1"/jest-e2e.json --runInBand
|
||||
node /usr/src/app/node_modules/.bin/jest --config e2e/$1/jest-e2e.json --runInBand
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import path from 'node:path';
|
||||
|
||||
export default async () => {
|
||||
let IMMICH_TEST_ASSET_PATH: string = '';
|
||||
|
||||
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
||||
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`);
|
||||
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
||||
} else {
|
||||
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
||||
}
|
||||
|
||||
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
@@ -21,9 +11,6 @@ export default async () => {
|
||||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.LOG_LEVEL = 'fatal';
|
||||
process.env.TZ = 'Z';
|
||||
|
||||
if (process.env.LOG_LEVEL === undefined) {
|
||||
process.env.LOG_LEVEL = 'fatal';
|
||||
}
|
||||
};
|
||||
|
||||
294
server/e2e/api/specs/auth.e2e-spec.ts
Normal file
294
server/e2e/api/specs/auth.e2e-spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { AuthController } from '@app/immich';
|
||||
import {
|
||||
adminSignupStub,
|
||||
changePasswordStub,
|
||||
deviceStub,
|
||||
errorStub,
|
||||
loginResponseStub,
|
||||
loginStub,
|
||||
uuidStub,
|
||||
} from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
||||
const name = 'Immich Admin';
|
||||
const password = 'Password123';
|
||||
const email = 'admin@immich.app';
|
||||
|
||||
const adminSignupResponse = {
|
||||
avatarColor: expect.any(String),
|
||||
id: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
email: 'admin@immich.app',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
profileImagePath: '',
|
||||
// why? lol
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
memoriesEnabled: true,
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
};
|
||||
|
||||
describe(`${AuthController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
const response = await api.authApi.adminLogin(server);
|
||||
accessToken = response.accessToken;
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
});
|
||||
|
||||
const invalid = [
|
||||
{
|
||||
should: 'require an email address',
|
||||
data: { name, password },
|
||||
},
|
||||
{
|
||||
should: 'require a password',
|
||||
data: { name, email },
|
||||
},
|
||||
{
|
||||
should: 'require a name',
|
||||
data: { email, password },
|
||||
},
|
||||
{
|
||||
should: 'require a valid email',
|
||||
data: { name, email: 'immich', password },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(server).post('/auth/admin-sign-up').send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it(`should sign up the admin`, async () => {
|
||||
await api.authApi.adminSignUp(server);
|
||||
});
|
||||
|
||||
it('should sign up the admin with a local domain', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...adminSignupStub, email: 'admin@local' });
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual({ ...adminSignupResponse, email: 'admin@local' });
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' });
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual(adminSignupResponse);
|
||||
});
|
||||
|
||||
it('should not allow a second admin to sign up', async () => {
|
||||
await api.authApi.adminSignUp(server);
|
||||
|
||||
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.alreadyHasAdmin);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(adminSignupStub)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...adminSignupStub, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe(`POST /auth/login`, () => {
|
||||
it('should reject an incorrect password', async () => {
|
||||
const { status, body } = await request(server).post('/auth/login').send({ email, password: 'incorrect' });
|
||||
expect(body).toEqual(errorStub.incorrectLogin);
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(loginStub.admin)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/auth/login')
|
||||
.send({ ...loginStub.admin, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should accept a correct password', async () => {
|
||||
const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(loginResponseStub.admin.response);
|
||||
|
||||
const token = body.accessToken;
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const cookies = headers['set-cookie'];
|
||||
expect(cookies).toHaveLength(3);
|
||||
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
|
||||
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/devices', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/auth/devices');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should get a list of authorized devices', async () => {
|
||||
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([deviceStub.current]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /auth/devices', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).delete(`/auth/devices`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should logout all devices (except the current one)', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await api.authApi.adminLogin(server);
|
||||
}
|
||||
|
||||
await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
|
||||
|
||||
const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
await api.authApi.validateToken(server, accessToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /auth/devices/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).delete(`/auth/devices/${uuidStub.notFound}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should throw an error for a non-existent device id', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/auth/devices/${uuidStub.notFound}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('Not found or no authDevice.delete access'));
|
||||
});
|
||||
|
||||
it('should logout a device', async () => {
|
||||
const [device] = await api.authApi.getAuthDevices(server, accessToken);
|
||||
const { status } = await request(server)
|
||||
.delete(`/auth/devices/${device.id}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(response.body).toEqual(errorStub.invalidToken);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/validateToken', () => {
|
||||
it('should reject an invalid token', async () => {
|
||||
const { status, body } = await request(server).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidToken);
|
||||
});
|
||||
|
||||
it('should accept a valid token', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/auth/validateToken`)
|
||||
.send({})
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ authStatus: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/change-password', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/auth/change-password`).send(changePasswordStub);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(changePasswordStub)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/auth/change-password')
|
||||
.send({ ...changePasswordStub, [key]: null })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should require the current password', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/auth/change-password`)
|
||||
.send({ ...changePasswordStub, password: 'wrong-password' })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.wrongPassword);
|
||||
});
|
||||
|
||||
it('should change the password', async () => {
|
||||
const { status } = await request(server)
|
||||
.post(`/auth/change-password`)
|
||||
.send(changePasswordStub)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
|
||||
await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/auth/logout`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should logout the user', async () => {
|
||||
const { status, body } = await request(server).post(`/auth/logout`).set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0' });
|
||||
});
|
||||
});
|
||||
});
|
||||
89
server/e2e/api/specs/download.e2e-spec.ts
Normal file
89
server/e2e/api/specs/download.e2e-spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { AssetResponseDto, IAssetRepository, LibraryResponseDto, LoginResponseDto, mapAsset } from '@app/domain';
|
||||
import { AssetController } from '@app/immich';
|
||||
import { AssetEntity } from '@app/infra/entities';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { errorStub, userDto } from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { generateAsset, testApp } from '../utils';
|
||||
|
||||
describe(`${AssetController.name} (e2e)`, () => {
|
||||
let app: INestApplication;
|
||||
let server: any;
|
||||
let assetRepository: IAssetRepository;
|
||||
let user1: LoginResponseDto;
|
||||
let libraries: LibraryResponseDto[];
|
||||
let asset1: AssetResponseDto;
|
||||
|
||||
const createAsset = async (
|
||||
loginResponse: LoginResponseDto,
|
||||
fileCreatedAt: Date,
|
||||
other: Partial<AssetEntity> = {},
|
||||
) => {
|
||||
const asset = await assetRepository.create(
|
||||
generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }),
|
||||
);
|
||||
|
||||
return mapAsset(asset);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||
|
||||
await testApp.reset();
|
||||
|
||||
await api.authApi.adminSignUp(server);
|
||||
const admin = await api.authApi.adminLogin(server);
|
||||
|
||||
await api.userApi.create(server, admin.accessToken, userDto.user1);
|
||||
user1 = await api.authApi.login(server, userDto.user1);
|
||||
libraries = await api.libraryApi.getAll(server, user1.accessToken);
|
||||
asset1 = await createAsset(user1, new Date('1970-01-01'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
describe('POST /download/info', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/download/info`)
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should download info', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/download/info')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset1.id] });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/asset/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/download/asset/${asset1.id}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should download file', async () => {
|
||||
const asset = await api.assetApi.upload(server, user1.accessToken, 'example');
|
||||
const response = await request(server)
|
||||
.post(`/download/asset/${asset.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toEqual('image/jpeg');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
|
||||
import { LibraryController } from '@app/immich';
|
||||
import { LibraryType } from '@app/infra/entities';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import { IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder } from 'src/test-utils/utils';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
@@ -21,7 +20,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreTempFolder();
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
@@ -249,16 +247,15 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
|
||||
const { status, body } = await request(server)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: [IMMICH_TEST_ASSET_TEMP_PATH] });
|
||||
.send({ importPaths: ['/path/to/import'] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
importPaths: [IMMICH_TEST_ASSET_TEMP_PATH],
|
||||
importPaths: ['/path/to/import'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -438,93 +435,4 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library/:id/validate', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/validate`).send({});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
describe('Validate import path', () => {
|
||||
let library: LibraryResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create an external library with default settings
|
||||
library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
|
||||
});
|
||||
|
||||
it('should fail with no external path set', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/library/${library.id}/validate`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
|
||||
.send({ importPaths: [] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('User has no external path set'));
|
||||
});
|
||||
|
||||
describe('With external path set', () => {
|
||||
beforeEach(async () => {
|
||||
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
|
||||
});
|
||||
|
||||
it('should pass with no import paths', async () => {
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
|
||||
expect(response.importPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not allow paths outside of the external path', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/../`;
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Not contained in user's external path`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path does not exist', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
|
||||
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path is a file', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
|
||||
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
30
server/e2e/api/specs/oauth.e2e-spec.ts
Normal file
30
server/e2e/api/specs/oauth.e2e-spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { OAuthController } from '@app/immich';
|
||||
import { errorStub } from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
||||
describe(`${OAuthController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
});
|
||||
|
||||
describe('POST /oauth/authorize', () => {
|
||||
it(`should throw an error if a redirect uri is not provided`, async () => {
|
||||
const { status, body } = await request(server).post('/oauth/authorize').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,68 @@
|
||||
import { LoginResponseDto, createPartner } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
|
||||
import { LoginResponseDto, PartnerDirection } from '@app/domain';
|
||||
import { PartnerController } from '@app/immich';
|
||||
import { errorStub, userDto } from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
||||
describe('/partner', () => {
|
||||
let admin: LoginResponseDto;
|
||||
describe(`${PartnerController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
let user1: LoginResponseDto;
|
||||
let user2: LoginResponseDto;
|
||||
let user3: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
admin = await apiUtils.adminSetup();
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
const admin = await api.authApi.adminLogin(server);
|
||||
|
||||
await Promise.all([
|
||||
api.userApi.create(server, admin.accessToken, userDto.user1),
|
||||
api.userApi.create(server, admin.accessToken, userDto.user2),
|
||||
api.userApi.create(server, admin.accessToken, userDto.user3),
|
||||
]);
|
||||
|
||||
[user1, user2, user3] = await Promise.all([
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
|
||||
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
|
||||
api.authApi.login(server, userDto.user1),
|
||||
api.authApi.login(server, userDto.user2),
|
||||
api.authApi.login(server, userDto.user3),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
createPartner(
|
||||
{ id: user2.userId },
|
||||
{ headers: asBearerAuth(user1.accessToken) }
|
||||
),
|
||||
createPartner(
|
||||
{ id: user1.userId },
|
||||
{ headers: asBearerAuth(user2.accessToken) }
|
||||
),
|
||||
api.partnerApi.create(server, user1.accessToken, user2.userId),
|
||||
api.partnerApi.create(server, user2.accessToken, user1.userId),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
describe('GET /partner', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/partner');
|
||||
const { status, body } = await request(server).get('/partner');
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should get all partners shared by user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/partner')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.query({ direction: 'shared-by' });
|
||||
.query({ direction: PartnerDirection.SharedBy });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
|
||||
});
|
||||
|
||||
it('should get all partners that share with user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/partner')
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.query({ direction: 'shared-with' });
|
||||
.query({ direction: PartnerDirection.SharedWith });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
|
||||
@@ -66,16 +71,14 @@ describe('/partner', () => {
|
||||
|
||||
describe('POST /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).post(
|
||||
`/partner/${user3.userId}`
|
||||
);
|
||||
const { status, body } = await request(server).post(`/partner/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should share with new partner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.post(`/partner/${user3.userId}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
@@ -84,52 +87,44 @@ describe('/partner', () => {
|
||||
});
|
||||
|
||||
it('should not share with new partner if already sharing with this partner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.post(`/partner/${user2.userId}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Partner already exists' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(
|
||||
`/partner/${user2.userId}`
|
||||
);
|
||||
const { status, body } = await request(server).put(`/partner/${user2.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.put(`/partner/${user2.userId}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ inTimeline: false });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ id: user2.userId, inTimeline: false })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /partner/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).delete(
|
||||
`/partner/${user3.userId}`
|
||||
);
|
||||
const { status, body } = await request(server).delete(`/partner/${user3.userId}`);
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should delete partner', async () => {
|
||||
const { status } = await request(app)
|
||||
const { status } = await request(server)
|
||||
.delete(`/partner/${user3.userId}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
@@ -137,14 +132,12 @@ describe('/partner', () => {
|
||||
});
|
||||
|
||||
it('should throw a bad request if partner not found', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.delete(`/partner/${user3.userId}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ message: 'Partner not found' })
|
||||
);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,38 @@
|
||||
import { LoginResponseDto, getServerConfig } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import { LoginResponseDto } from '@app/domain';
|
||||
import { ServerInfoController } from '@app/immich';
|
||||
import { errorStub, userDto } from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
||||
describe('/server-info', () => {
|
||||
describe(`${ServerInfoController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup({ onboarding: false });
|
||||
nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
await api.userApi.create(server, admin.accessToken, userDto.user1);
|
||||
nonAdmin = await api.authApi.login(server, userDto.user1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
describe('GET /server-info', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/server-info');
|
||||
const { status, body } = await request(server).get('/server-info');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should return the disk information', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/server-info')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
@@ -42,7 +50,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/ping', () => {
|
||||
it('should respond with pong', async () => {
|
||||
const { status, body } = await request(app).get('/server-info/ping');
|
||||
const { status, body } = await request(server).get('/server-info/ping');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ res: 'pong' });
|
||||
});
|
||||
@@ -50,7 +58,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/version', () => {
|
||||
it('should respond with the server version', async () => {
|
||||
const { status, body } = await request(app).get('/server-info/version');
|
||||
const { status, body } = await request(server).get('/server-info/version');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
major: expect.any(Number),
|
||||
@@ -62,12 +70,12 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/features', () => {
|
||||
it('should respond with the server features', async () => {
|
||||
const { status, body } = await request(app).get('/server-info/features');
|
||||
const { status, body } = await request(server).get('/server-info/features');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
smartSearch: false,
|
||||
smartSearch: true,
|
||||
configFile: false,
|
||||
facialRecognition: false,
|
||||
facialRecognition: true,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
oauth: false,
|
||||
@@ -82,7 +90,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/config', () => {
|
||||
it('should respond with the server configuration', async () => {
|
||||
const { status, body } = await request(app).get('/server-info/config');
|
||||
const { status, body } = await request(server).get('/server-info/config');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
loginPageMessage: '',
|
||||
@@ -97,23 +105,21 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/statistics', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/server-info/statistics'
|
||||
);
|
||||
const { status, body } = await request(server).get('/server-info/statistics');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should only work for admins', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/server-info/statistics')
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.forbidden);
|
||||
expect(body).toEqual(errorStub.forbidden);
|
||||
});
|
||||
|
||||
it('should return the server stats', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/server-info/statistics')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
@@ -145,9 +151,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/media-types', () => {
|
||||
it('should return accepted media types', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/server-info/media-types'
|
||||
);
|
||||
const { status, body } = await request(server).get('/server-info/media-types');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
sidecar: ['.xmp'],
|
||||
@@ -159,7 +163,7 @@ describe('/server-info', () => {
|
||||
|
||||
describe('GET /server-info/theme', () => {
|
||||
it('should respond with the server theme', async () => {
|
||||
const { status, body } = await request(app).get('/server-info/theme');
|
||||
const { status, body } = await request(server).get('/server-info/theme');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
customCss: '',
|
||||
@@ -169,15 +173,15 @@ describe('/server-info', () => {
|
||||
|
||||
describe('POST /server-info/admin-onboarding', () => {
|
||||
it('should set admin onboarding', async () => {
|
||||
const config = await getServerConfig({});
|
||||
const config = await api.serverInfoApi.getConfig(server);
|
||||
expect(config.isOnboarded).toBe(false);
|
||||
|
||||
const { status } = await request(app)
|
||||
const { status } = await request(server)
|
||||
.post('/server-info/admin-onboarding')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(204);
|
||||
|
||||
const newConfig = await getServerConfig({});
|
||||
const newConfig = await api.serverInfoApi.getConfig(server);
|
||||
expect(newConfig.isOnboarded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,49 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { apiUtils, app, dbUtils } from 'src/utils';
|
||||
import { LoginResponseDto } from '@app/domain';
|
||||
import { SystemConfigController } from '@app/immich';
|
||||
import { errorStub, userDto } from '@test/fixtures';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
||||
describe('/system-config', () => {
|
||||
describe(`${SystemConfigController.name} (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
apiUtils.setup();
|
||||
await dbUtils.reset();
|
||||
admin = await apiUtils.adminSetup();
|
||||
nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
await api.userApi.create(server, admin.accessToken, userDto.user1);
|
||||
nonAdmin = await api.authApi.login(server, userDto.user1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
describe('GET /system-config/map/style.json', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(
|
||||
'/system-config/map/style.json'
|
||||
);
|
||||
const { status, body } = await request(server).get('/system-config/map/style.json');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should throw an error if a theme is not light or dark', async () => {
|
||||
for (const theme of ['dark1', true, 123, '', null, undefined]) {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/system-config/map/style.json')
|
||||
.query({ theme })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'theme must be one of the following values: light, dark',
|
||||
])
|
||||
);
|
||||
expect(body).toEqual(errorStub.badRequest(['theme must be one of the following values: light, dark']));
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the light style.json', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/system-config/map/style.json')
|
||||
.query({ theme: 'light' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -50,7 +52,7 @@ describe('/system-config', () => {
|
||||
});
|
||||
|
||||
it('should return the dark style.json', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/system-config/map/style.json')
|
||||
.query({ theme: 'dark' })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
@@ -59,7 +61,7 @@ describe('/system-config', () => {
|
||||
});
|
||||
|
||||
it('should not require admin authentication', async () => {
|
||||
const { status, body } = await request(app)
|
||||
const { status, body } = await request(server)
|
||||
.get('/system-config/map/style.json')
|
||||
.query({ theme: 'dark' })
|
||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
||||
299
server/e2e/api/specs/user.e2e-spec.ts
Normal file
299
server/e2e/api/specs/user.e2e-spec.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
|
||||
import { AppModule, UserController } from '@app/immich';
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
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 { testApp } from '../utils';
|
||||
|
||||
describe(`${UserController.name}`, () => {
|
||||
let app: INestApplication;
|
||||
let server: any;
|
||||
let loginResponse: LoginResponseDto;
|
||||
let accessToken: string;
|
||||
let userService: UserService;
|
||||
let userRepository: Repository<UserEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
loginResponse = await api.authApi.adminLogin(server);
|
||||
accessToken = loginResponse.accessToken;
|
||||
|
||||
userService = app.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
describe('GET /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get('/user');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should start with the admin', async () => {
|
||||
const { status, body } = await request(server).get('/user').set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
|
||||
});
|
||||
|
||||
it('should hide deleted users', async () => {
|
||||
const user1 = await api.userApi.create(server, accessToken, {
|
||||
email: `user1@immich.app`,
|
||||
password: 'Password123',
|
||||
name: `User 1`,
|
||||
});
|
||||
|
||||
await api.userApi.delete(server, accessToken, user1.id);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get(`/user`)
|
||||
.query({ isAll: true })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
|
||||
});
|
||||
|
||||
it('should include deleted users', async () => {
|
||||
const user1 = await api.userApi.create(server, accessToken, userDto.user1);
|
||||
|
||||
await api.userApi.delete(server, accessToken, user1.id);
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get(`/user`)
|
||||
.query({ isAll: false })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]).toMatchObject({ id: user1.id, email: 'user1@immich.app', deletedAt: expect.any(String) });
|
||||
expect(body[1]).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /user/info/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(server).get(`/user/info/${loginResponse.userId}`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
|
||||
it('should get the user info', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.get(`/user/info/${loginResponse.userId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /user/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).get(`/user/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should get my info', async () => {
|
||||
const { status, body } = await request(server).get(`/user/me`).set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/user`).send(userSignupStub);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(userSignupStub)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/user`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ ...userSignupStub, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should ignore `isAdmin`', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/user`)
|
||||
.send({
|
||||
isAdmin: true,
|
||||
email: 'user1@immich.app',
|
||||
password: 'Password123',
|
||||
name: 'Immich',
|
||||
})
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(body).toMatchObject({
|
||||
email: 'user1@immich.app',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should create a user without memories enabled', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/user`)
|
||||
.send({
|
||||
email: 'no-memories@immich.app',
|
||||
password: 'Password123',
|
||||
name: 'No Memories',
|
||||
memoriesEnabled: false,
|
||||
})
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
expect(body).toMatchObject({
|
||||
email: 'no-memories@immich.app',
|
||||
memoriesEnabled: false,
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /user/:id', () => {
|
||||
let userToDelete: UserResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
userToDelete = await api.userApi.create(server, accessToken, {
|
||||
email: userStub.user1.email,
|
||||
name: userStub.user1.name,
|
||||
password: 'superSecurePassword',
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).delete(`/user/${userToDelete.id}`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
const deleteRequest = await request(server)
|
||||
.delete(`/user/${userToDelete.id}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(deleteRequest.status).toBe(200);
|
||||
expect(deleteRequest.body).toEqual({
|
||||
...userToDelete,
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: expect.any(String),
|
||||
});
|
||||
|
||||
await userRepository.save({ id: deleteRequest.body.id, deletedAt: new Date('1970-01-01').toISOString() });
|
||||
|
||||
await userService.handleUserDelete({ id: userToDelete.id });
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/user')
|
||||
.query({ isAll: false })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /user', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).put(`/user`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(userStub.admin)) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put(`/user`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ ...userStub.admin, [key]: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should not allow a non-admin to become an admin', async () => {
|
||||
const user = await api.userApi.create(server, accessToken, {
|
||||
email: 'user1@immich.app',
|
||||
password: 'Password123',
|
||||
name: 'Immich User',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.put(`/user`)
|
||||
.send({ isAdmin: true, id: user.id })
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.alreadyHasAdmin);
|
||||
});
|
||||
|
||||
it('ignores updates to profileImagePath', async () => {
|
||||
const user = await api.userApi.update(server, accessToken, {
|
||||
id: loginResponse.userId,
|
||||
profileImagePath: 'invalid.jpg',
|
||||
} as any);
|
||||
|
||||
expect(user).toMatchObject({ id: loginResponse.userId, profileImagePath: '' });
|
||||
});
|
||||
|
||||
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
|
||||
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
|
||||
const after = await api.userApi.update(server, accessToken, {
|
||||
id: loginResponse.userId,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
deletedAt: '2023-01-01T00:00:00.000Z',
|
||||
} as any);
|
||||
|
||||
expect(after).toStrictEqual(before);
|
||||
});
|
||||
|
||||
it('should update first and last name', async () => {
|
||||
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
|
||||
const after = await api.userApi.update(server, accessToken, {
|
||||
id: before.id,
|
||||
name: 'Name',
|
||||
});
|
||||
|
||||
expect(after).toEqual({
|
||||
...before,
|
||||
updatedAt: expect.any(String),
|
||||
name: 'Name',
|
||||
});
|
||||
expect(before.updatedAt).not.toEqual(after.updatedAt);
|
||||
});
|
||||
|
||||
it('should update memories enabled', async () => {
|
||||
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
|
||||
const after = await api.userApi.update(server, accessToken, {
|
||||
id: before.id,
|
||||
memoriesEnabled: false,
|
||||
});
|
||||
|
||||
expect(after).toMatchObject({
|
||||
...before,
|
||||
updatedAt: expect.anything(),
|
||||
memoriesEnabled: false,
|
||||
});
|
||||
expect(before.updatedAt).not.toEqual(after.updatedAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
LibraryStatsResponseDto,
|
||||
ScanLibraryDto,
|
||||
UpdateLibraryDto,
|
||||
ValidateLibraryDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from '@app/domain';
|
||||
import request from 'supertest';
|
||||
|
||||
@@ -60,12 +58,4 @@ export const libraryApi = {
|
||||
expect(status).toBe(200);
|
||||
return body as LibraryResponseDto;
|
||||
},
|
||||
validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => {
|
||||
const { body, status } = await request(server)
|
||||
.post(`/library/${id}/validate`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(data);
|
||||
expect(status).toBe(200);
|
||||
return body as ValidateLibraryResponseDto;
|
||||
},
|
||||
};
|
||||
|
||||
3089
server/package-lock.json
generated
3089
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user