Compare commits
117 commits
main
...
feat/cli-e
Author | SHA1 | Date | |
---|---|---|---|
|
8dac9ed053 | ||
|
21c73baf30 | ||
|
76a1abe7bd | ||
|
50627a0bb5 | ||
|
1279d42e0b | ||
|
e9fc2b8ff7 | ||
|
7c2634b7cb | ||
|
fd1eb95220 | ||
|
0bfb6ea235 | ||
|
ac026f32cb | ||
|
e35b9d51f2 | ||
|
53657c09a5 | ||
|
b2df2f59c4 | ||
|
35359585cb | ||
|
d2ecb5ca4d | ||
|
3593bda2fe | ||
|
daad3efb19 | ||
|
fb69b59314 | ||
|
4a40a48c34 | ||
|
7c6ee25f89 | ||
|
19f83f3c79 | ||
|
ae3cb5adf6 | ||
|
8d4bc7f6a8 | ||
|
f6889c4d45 | ||
|
c7ed7877f5 | ||
|
191e8fcd77 | ||
|
f63bd80dce | ||
|
6a423433ed | ||
|
507c7e963c | ||
|
ca381bd2ac | ||
|
f5df0bc6f2 | ||
|
46d35c74c5 | ||
|
c8d417ccda | ||
|
c124282514 | ||
|
6421fcf2f8 | ||
|
eb939cd4f6 | ||
|
57ff53beb6 | ||
|
bbe6795e33 | ||
|
e1c3634490 | ||
|
1080d6a26e | ||
|
cf5fb63759 | ||
|
d976140227 | ||
|
dfbabf5da7 | ||
|
8815d81d00 | ||
|
04954c5616 | ||
|
713b13cc57 | ||
|
92e4bbca23 | ||
|
c5002693a8 | ||
|
c45f68bc46 | ||
|
0388a8a79b | ||
|
6d3471b6f3 | ||
|
b11d7668a6 | ||
|
ca97af6c12 | ||
|
7257780ecd | ||
|
74a336d1e6 | ||
|
4e099cc8cb | ||
|
afe1470965 | ||
|
b00a7d63cf | ||
|
3ca69601a8 | ||
|
2981a383d2 | ||
|
5ede8a8bcc | ||
|
3c8e3e6e8b | ||
|
6e4a1c10d0 | ||
|
ef3d2683d9 | ||
|
ec37247d95 | ||
|
c776ef4f16 | ||
|
0efd9a65b6 | ||
|
b0b8ce2d6d | ||
|
7a2c27484c | ||
|
36e8397ce2 | ||
|
9ba8565730 | ||
|
969b0b7367 | ||
|
4c3a1ca168 | ||
|
11adf2e906 | ||
|
5e56b65b38 | ||
|
ebd408e06a | ||
|
f77b833eea | ||
|
9ba35e9bbd | ||
|
448f5f9227 | ||
|
11c4051bd8 | ||
|
67827e0929 | ||
|
1724eb6f6f | ||
|
a9cbd573f4 | ||
|
6e04f41f1e | ||
|
4be93e5af8 | ||
|
ef3a37726b | ||
|
60a622aa33 | ||
|
8ee629fd16 | ||
|
927718fa32 | ||
|
eee3b7f789 | ||
|
8471bf569e | ||
|
ad7ac83c2e | ||
|
97e2ea9607 | ||
|
b4ca48b246 | ||
|
7c40e36e35 | ||
|
633def4955 | ||
|
89993314b1 | ||
|
1c4cd3eea4 | ||
|
b438c6b514 | ||
|
0d39474fc9 | ||
|
b8c5744e0f | ||
|
d68a9d2c5e | ||
|
02152082d1 | ||
|
f962e8be42 | ||
|
39026072be | ||
|
e76c871740 | ||
|
015d8d8267 | ||
|
f588416659 | ||
|
db28694f68 | ||
|
06d78d2dac | ||
|
9d25a60455 | ||
|
55615bcd15 | ||
|
b2bbdf423a | ||
|
a983eddb55 | ||
|
1b7c8a29cb | ||
|
4439bc2355 | ||
|
1d385b6cbe |
47 changed files with 759 additions and 148 deletions
|
@ -1,5 +1,5 @@
|
|||
.vscode/
|
||||
cli/
|
||||
|
||||
design/
|
||||
docker/
|
||||
docs/
|
||||
|
@ -18,3 +18,8 @@ web/node_modules/
|
|||
web/coverage/
|
||||
web/.svelte-kit
|
||||
web/build/
|
||||
|
||||
cli/node_modules
|
||||
cli/.reverse-geocoding-dump/
|
||||
cli/upload/
|
||||
cli/dist/
|
26
.github/workflows/test.yml
vendored
26
.github/workflows/test.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
submodules: "recursive"
|
||||
|
||||
- name: Run e2e tests
|
||||
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
run: make test-server-e2e
|
||||
|
||||
doc-tests:
|
||||
name: Docs
|
||||
|
@ -90,9 +90,14 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
- name: Run npm install in cli
|
||||
run: npm ci
|
||||
|
||||
- uses: chill-viking/npm-ci@latest
|
||||
name: Run npm install in server
|
||||
with:
|
||||
working_directory: "server"
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
@ -109,6 +114,23 @@ jobs:
|
|||
run: npm run test:cov
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
cli-e2e-tests:
|
||||
name: CLI (e2e)
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest] # TODO: macos and windows
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run e2e tests
|
||||
run: make test-cli-e2e
|
||||
|
||||
web-unit-tests:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
|
|
7
Makefile
7
Makefile
|
@ -16,8 +16,11 @@ stage:
|
|||
pull-stage:
|
||||
docker compose -f ./docker/docker-compose.staging.yml pull
|
||||
|
||||
test-e2e:
|
||||
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
test-server-e2e:
|
||||
docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
|
||||
test-cli-e2e:
|
||||
docker compose -f ./cli/test/docker-compose.cli-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-cli --remove-orphans --build
|
||||
|
||||
prod:
|
||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
|
2
cli/.gitignore
vendored
2
cli/.gitignore
vendored
|
@ -11,3 +11,5 @@ oclif.manifest.json
|
|||
.vscode
|
||||
.idea
|
||||
/coverage/
|
||||
.reverse-geocoding-dump/
|
||||
upload/
|
|
@ -1,4 +1,6 @@
|
|||
**/*.spec.js
|
||||
test/**
|
||||
upload/**
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
|
|
19
cli/Dockerfile
Normal file
19
cli/Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
|||
FROM ghcr.io/immich-app/base-server-dev:20231109 as test
|
||||
|
||||
WORKDIR /usr/src/app/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./server/ .
|
||||
|
||||
WORKDIR /usr/src/app/cli
|
||||
COPY cli/package.json cli/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./cli/ .
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:20231109
|
||||
|
||||
VOLUME /usr/src/app/upload
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
231
cli/package-lock.json
generated
231
cli/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
|
@ -37,6 +37,7 @@
|
|||
"eslint-plugin-jest": "^27.2.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^49.0.0",
|
||||
"immich": "file:../server",
|
||||
"jest": "^29.5.0",
|
||||
"jest-extended": "^4.0.0",
|
||||
"jest-message-util": "^29.5.0",
|
||||
|
@ -49,6 +50,106 @@
|
|||
"typescript": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"../server": {
|
||||
"version": "1.88.2",
|
||||
"dev": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
"@immich/cli": "^2.0.3",
|
||||
"@nestjs/bullmq": "^10.0.1",
|
||||
"@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": "^3.0.3",
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"archiver": "^6.0.0",
|
||||
"axios": "^1.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^4.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exiftool-vendored": "~23.5.0",
|
||||
"exiftool-vendored.pl": "12.70",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^7.0.7",
|
||||
"glob": "^10.3.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"i18n-iso-countries": "^7.6.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.10.0",
|
||||
"local-reverse-geocoder": "0.16.5",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.11.1",
|
||||
"openid-client": "^5.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.32.6",
|
||||
"thumbhash": "^0.1.1",
|
||||
"typeorm": "^0.3.17",
|
||||
"typesense": "^1.7.1",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
},
|
||||
"bin": {
|
||||
"immich": "bin/cli.sh",
|
||||
"immich-admin": "bin/admin-cli.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.1.16",
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/testing": "^10.2.2",
|
||||
"@openapitools/openapi-generator-cli": "2.7.0",
|
||||
"@testcontainers/postgresql": "^10.2.1",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fluent-ffmpeg": "^2.1.21",
|
||||
"@types/imagemin": "^8.0.1",
|
||||
"@types/jest": "29.5.9",
|
||||
"@types/jest-when": "^3.5.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/mv": "^2.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.6.4",
|
||||
"jest-when": "^3.6.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"testcontainers": "^10.2.1",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"utimes": "^5.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
|
||||
|
@ -1519,9 +1620,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "4.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz",
|
||||
"integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==",
|
||||
"version": "4.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
|
||||
"integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cli-progress": {
|
||||
|
@ -1567,9 +1668,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/jest": {
|
||||
"version": "29.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz",
|
||||
"integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==",
|
||||
"version": "29.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
|
||||
"integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"expect": "^29.0.0",
|
||||
|
@ -3891,6 +3992,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",
|
||||
|
@ -7905,9 +8010,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"@types/chai": {
|
||||
"version": "4.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz",
|
||||
"integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==",
|
||||
"version": "4.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.10.tgz",
|
||||
"integrity": "sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cli-progress": {
|
||||
|
@ -7953,9 +8058,9 @@
|
|||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "29.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz",
|
||||
"integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==",
|
||||
"version": "29.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz",
|
||||
"integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"expect": "^29.0.0",
|
||||
|
@ -7990,9 +8095,9 @@
|
|||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
|
||||
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
|
||||
"version": "20.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz",
|
||||
"integrity": "sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~5.26.4"
|
||||
|
@ -9547,6 +9652,98 @@
|
|||
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"immich": {
|
||||
"version": "file:../server",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
"@immich/cli": "^2.0.3",
|
||||
"@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": "^3.0.3",
|
||||
"@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",
|
||||
"@openapitools/openapi-generator-cli": "2.7.0",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"@testcontainers/postgresql": "^10.2.1",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fluent-ffmpeg": "^2.1.21",
|
||||
"@types/imagemin": "^8.0.1",
|
||||
"@types/jest": "29.5.9",
|
||||
"@types/jest-when": "^3.5.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/mv": "^2.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"archiver": "^6.0.0",
|
||||
"axios": "^1.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^4.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"exiftool-vendored": "~23.5.0",
|
||||
"exiftool-vendored.pl": "12.70",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^7.0.7",
|
||||
"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",
|
||||
"local-reverse-geocoder": "0.16.5",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mock-fs": "^5.2.0",
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.11.1",
|
||||
"openid-client": "^5.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"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.32.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"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.2.2",
|
||||
"typesense": "^1.7.1",
|
||||
"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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
@ -35,6 +35,7 @@
|
|||
"eslint": "^8.43.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.2.2",
|
||||
"immich": "file:../server",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^49.0.0",
|
||||
"jest": "^29.5.0",
|
||||
|
@ -50,13 +51,15 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prepack": "npm run build",
|
||||
"test": "jest",
|
||||
"test:cov": "jest --coverage",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand"
|
||||
},
|
||||
"jest": {
|
||||
"clearMocks": true,
|
||||
|
@ -71,10 +74,15 @@
|
|||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.(t|j)s"
|
||||
"<rootDir>/src/**/*.(t|j)s",
|
||||
"!**/open-api/**"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@api(|/.*)$": "<rootDir>/src/api/$1"
|
||||
"^@api(|/.*)$": "<rootDir>/src/api/$1",
|
||||
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||
"^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
|
||||
},
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node"
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { ImmichApi } from '../api/client';
|
||||
import path from 'node:path';
|
||||
import { SessionService } from '../services/session.service';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
import { exit } from 'node:process';
|
||||
import os from 'os';
|
||||
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
|
||||
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||
|
||||
export abstract class BaseCommand {
|
||||
protected sessionService!: SessionService;
|
||||
|
@ -12,14 +11,11 @@ export abstract class BaseCommand {
|
|||
protected user!: UserResponseDto;
|
||||
protected serverVersion!: ServerVersionResponseDto;
|
||||
|
||||
protected configDir;
|
||||
protected authPath;
|
||||
|
||||
constructor() {
|
||||
const userHomeDir = os.homedir();
|
||||
this.configDir = path.join(userHomeDir, '.config/immich/');
|
||||
this.sessionService = new SessionService(this.configDir);
|
||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
||||
constructor(options: BaseOptionsDto) {
|
||||
if (!options.config) {
|
||||
throw new Error('Config directory is required');
|
||||
}
|
||||
this.sessionService = new SessionService(options.config);
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
|
|||
import { CrawlService } from '../services';
|
||||
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
|
||||
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import cliProgress from 'cli-progress';
|
||||
import byteSize from 'byte-size';
|
||||
import { BaseCommand } from '../cli/base-command';
|
||||
|
@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
|
|||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
||||
await this.connect();
|
||||
|
||||
const deviceId = 'CLI';
|
||||
|
||||
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
|
||||
|
||||
|
@ -25,14 +23,26 @@ export default class Upload extends BaseCommand {
|
|||
crawlOptions.recursive = options.recursive;
|
||||
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
for (const pathArgument of paths) {
|
||||
const fileStat = await fs.promises.lstat(pathArgument);
|
||||
|
||||
if (fileStat.isFile()) {
|
||||
files.push(pathArgument);
|
||||
}
|
||||
}
|
||||
|
||||
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
|
||||
|
||||
crawledFiles.push(...files);
|
||||
|
||||
if (crawledFiles.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
|
||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
|
|
37
cli/src/constants.ts
Normal file
37
cli/src/constants.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import pkg from '../package.json';
|
||||
|
||||
export interface ICLIVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export class CLIVersion implements ICLIVersion {
|
||||
constructor(
|
||||
public readonly major: number,
|
||||
public readonly minor: number,
|
||||
public readonly patch: number,
|
||||
) {}
|
||||
|
||||
toString() {
|
||||
return `${this.major}.${this.minor}.${this.patch}`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const { major, minor, patch } = this;
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
static fromString(version: string): CLIVersion {
|
||||
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
|
||||
const matchResult = version.match(regex);
|
||||
if (matchResult) {
|
||||
const [, major, minor, patch] = matchResult.map(Number);
|
||||
return new CLIVersion(major, minor, patch);
|
||||
} else {
|
||||
throw new Error(`Invalid version format: ${version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cliVersion = CLIVersion.fromString(pkg.version);
|
3
cli/src/cores/dto/base-options-dto.ts
Normal file
3
cli/src/cores/dto/base-options-dto.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export class BaseOptionsDto {
|
||||
config?: string;
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
export class UploadOptionsDto {
|
||||
recursive = false;
|
||||
exclusionPatterns!: string[];
|
||||
dryRun = false;
|
||||
skipHash = false;
|
||||
delete = false;
|
||||
readOnly = true;
|
||||
album = false;
|
||||
recursive? = false;
|
||||
exclusionPatterns?: string[] = [];
|
||||
dryRun? = false;
|
||||
skipHash? = false;
|
||||
delete? = false;
|
||||
album? = false;
|
||||
}
|
||||
|
|
|
@ -2,10 +2,8 @@ export class LoginError extends Error {
|
|||
constructor(message: string) {
|
||||
super(message);
|
||||
|
||||
// assign the error class name in your custom error (as a shortcut)
|
||||
this.name = this.constructor.name;
|
||||
|
||||
// capturing the stack trace keeps the reference to your error class
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,8 @@ export class Asset {
|
|||
fileSize!: number;
|
||||
albumName?: string;
|
||||
|
||||
constructor(path: string, deviceId: string) {
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
async process() {
|
||||
|
@ -45,12 +44,11 @@ export class Asset {
|
|||
if (!this.deviceAssetId) throw new Error('Device asset id not set');
|
||||
if (!this.fileCreatedAt) throw new Error('File created at not set');
|
||||
if (!this.fileModifiedAt) throw new Error('File modified at not set');
|
||||
if (!this.deviceId) throw new Error('Device id not set');
|
||||
|
||||
const data: any = {
|
||||
assetData: this.assetData as any,
|
||||
deviceAssetId: this.deviceAssetId,
|
||||
deviceId: this.deviceId,
|
||||
deviceId: 'CLI',
|
||||
fileCreatedAt: this.fileCreatedAt,
|
||||
fileModifiedAt: this.fileModifiedAt,
|
||||
isFavorite: String(false),
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
#! /usr/bin/env node
|
||||
|
||||
import { program, Option } from 'commander';
|
||||
import { Option, Command } from 'commander';
|
||||
import Upload from './commands/upload';
|
||||
import ServerInfo from './commands/server-info';
|
||||
import LoginKey from './commands/login/key';
|
||||
import Logout from './commands/logout';
|
||||
import { version } from '../package.json';
|
||||
|
||||
program.name('immich').description('Immich command line interface').version(version);
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
|
||||
const userHomeDir = os.homedir();
|
||||
const configDir = path.join(userHomeDir, '.config/immich/');
|
||||
|
||||
const program = new Command()
|
||||
.name('immich')
|
||||
.version(version)
|
||||
.description('Command line interface for Immich')
|
||||
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
|
||||
|
||||
program
|
||||
.command('upload')
|
||||
|
@ -30,14 +40,14 @@ program
|
|||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||
.action(async (paths, options) => {
|
||||
options.exclusionPatterns = options.ignore;
|
||||
await new Upload().run(paths, options);
|
||||
await new Upload(program.opts()).run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('server-info')
|
||||
.description('Display server information')
|
||||
.action(async () => {
|
||||
await new ServerInfo().run();
|
||||
await new ServerInfo(program.opts()).run();
|
||||
});
|
||||
|
||||
program
|
||||
|
@ -46,14 +56,14 @@ program
|
|||
.argument('[instanceUrl]')
|
||||
.argument('[apiKey]')
|
||||
.action(async (paths, options) => {
|
||||
await new LoginKey().run(paths, options);
|
||||
await new LoginKey(program.opts()).run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('logout')
|
||||
.description('Remove stored credentials')
|
||||
.action(async () => {
|
||||
await new Logout().run();
|
||||
await new Logout(program.opts()).run();
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
import { SessionService } from './session.service';
|
||||
import mockfs from 'mock-fs';
|
||||
import fs from 'node:fs';
|
||||
import yaml from 'yaml';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
import {
|
||||
TEST_AUTH_FILE,
|
||||
TEST_CONFIG_DIR,
|
||||
TEST_IMMICH_API_KEY,
|
||||
TEST_IMMICH_INSTANCE_URL,
|
||||
createTestAuthFile,
|
||||
deleteAuthFile,
|
||||
readTestAuthFile,
|
||||
spyOnConsole,
|
||||
} from '../../test/cli-test-utils';
|
||||
|
||||
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
|
||||
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
|
||||
|
@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
|
|||
|
||||
describe('SessionService', () => {
|
||||
let sessionService: SessionService;
|
||||
let consoleSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
// Write a dummy output before mock-fs to prevent some annoying errors
|
||||
console.log();
|
||||
consoleSpy = spyOnConsole();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const configDir = '/config';
|
||||
sessionService = new SessionService(configDir);
|
||||
deleteAuthFile();
|
||||
sessionService = new SessionService(TEST_CONFIG_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
deleteAuthFile();
|
||||
});
|
||||
|
||||
it('should connect to immich', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
|
||||
await sessionService.connect();
|
||||
expect(mockPingServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should error if no auth file exists', async () => {
|
||||
mockfs();
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error.message).toEqual('No auth file exist. Please login first');
|
||||
});
|
||||
});
|
||||
|
||||
it('should error if auth file is missing instance URl', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
}),
|
||||
);
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error).toBeInstanceOf(LoginError);
|
||||
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
|
||||
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should error if auth file is missing api key', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
|
||||
});
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error).toBeInstanceOf(LoginError);
|
||||
expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(sessionService.connect()).rejects.toThrow(
|
||||
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('should create auth file when logged in', async () => {
|
||||
mockfs();
|
||||
it('should create auth file when logged in', async () => {
|
||||
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
|
||||
|
||||
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
|
||||
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
|
||||
const data: string = await readTestAuthFile();
|
||||
const authConfig = yaml.parse(data);
|
||||
expect(authConfig.instanceUrl).toBe('https://test/api');
|
||||
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
|
||||
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
|
||||
});
|
||||
|
||||
it('should delete auth file when logging out', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
await sessionService.logout();
|
||||
|
||||
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
|
||||
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
|
||||
expect(error.message).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,16 +5,20 @@ import { ImmichApi } from '../api/client';
|
|||
import { LoginError } from '../cores/errors/login-error';
|
||||
|
||||
export class SessionService {
|
||||
readonly configDir: string;
|
||||
readonly configDir!: string;
|
||||
readonly authPath!: string;
|
||||
private api!: ImmichApi;
|
||||
|
||||
constructor(configDir: string) {
|
||||
this.configDir = configDir;
|
||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
||||
this.authPath = path.join(configDir, '/auth.yml');
|
||||
}
|
||||
|
||||
public async connect(): Promise<ImmichApi> {
|
||||
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
let apiKey = process.env.IMMICH_API_KEY;
|
||||
|
||||
if (!instanceUrl || !apiKey) {
|
||||
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new LoginError('No auth file exist. Please login first');
|
||||
|
@ -23,15 +27,17 @@ export class SessionService {
|
|||
|
||||
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
|
||||
const parsedConfig = yaml.parse(data);
|
||||
const instanceUrl: string = parsedConfig.instanceUrl;
|
||||
const apiKey: string = parsedConfig.apiKey;
|
||||
|
||||
instanceUrl = parsedConfig.instanceUrl;
|
||||
apiKey = parsedConfig.apiKey;
|
||||
|
||||
if (!instanceUrl) {
|
||||
throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
|
||||
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new LoginError('API key missing in auth config file ' + this.authPath);
|
||||
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.api = new ImmichApi(instanceUrl, apiKey);
|
||||
|
@ -59,10 +65,6 @@ export class SessionService {
|
|||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
console.error('waah');
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
||||
|
||||
console.log('Wrote auth info to ' + this.authPath);
|
||||
|
@ -82,7 +84,7 @@ export class SessionService {
|
|||
});
|
||||
|
||||
if (pingResponse.res !== 'pong') {
|
||||
throw new Error('Unexpected ping reply');
|
||||
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
38
cli/test/cli-test-utils.ts
Normal file
38
cli/test/cli-test-utils.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const TEST_CONFIG_DIR = '/tmp/immich/';
|
||||
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
|
||||
export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
|
||||
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
|
||||
|
||||
export const CLI_BASE_OPTIONS: BaseOptionsDto = { config: TEST_CONFIG_DIR };
|
||||
|
||||
export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
export const createTestAuthFile = async (contents: string) => {
|
||||
if (!fs.existsSync(TEST_CONFIG_DIR)) {
|
||||
// Create config folder if it doesn't exist
|
||||
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
|
||||
if (!created) {
|
||||
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(TEST_AUTH_FILE, contents);
|
||||
};
|
||||
|
||||
export const readTestAuthFile = async (): Promise<string> => {
|
||||
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
|
||||
};
|
||||
|
||||
export const deleteAuthFile = () => {
|
||||
try {
|
||||
fs.unlinkSync(TEST_AUTH_FILE);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
31
cli/test/docker-compose.cli-e2e.yml
Normal file
31
cli/test/docker-compose.cli-e2e.yml
Normal file
|
@ -0,0 +1,31 @@
|
|||
version: '3.8'
|
||||
|
||||
name: 'immich-test-cli-e2e'
|
||||
|
||||
services:
|
||||
immich-cli:
|
||||
image: immich-cli-dev:latest
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: cli/Dockerfile
|
||||
target: test
|
||||
working_dir: /usr/src/app/cli
|
||||
command: npm run test:e2e
|
||||
environment:
|
||||
- DB_HOSTNAME=database
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=e2e_test
|
||||
- IMMICH_RUN_ALL_TESTS=true
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
database:
|
||||
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
|
||||
command: -c fsync=off
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: e2e_test
|
||||
logging:
|
||||
driver: none
|
24
cli/test/e2e/jest-e2e.json
Normal file
24
cli/test/e2e/jest-e2e.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"modulePaths": ["<rootDir>"],
|
||||
"rootDir": "../..",
|
||||
"globalSetup": "<rootDir>/../server/test/e2e/setup.ts",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"testTimeout": 6000000,
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.(t|j)s",
|
||||
"!<rootDir>/src/**/*.spec.(t|s)s",
|
||||
"!<rootDir>/src/infra/migrations/**"
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"moduleNameMapper": {
|
||||
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||
"^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
|
||||
}
|
||||
}
|
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { api } from '@test/api';
|
||||
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import LoginKey from 'src/commands/login/key';
|
||||
import { LoginError } from 'src/cores/errors/login-error';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`login-key (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
let instanceUrl: string;
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||
if (!process.env.IMMICH_INSTANCE_URL) {
|
||||
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
|
||||
} else {
|
||||
instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should error when providing an invalid API key', async () => {
|
||||
await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
|
||||
new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log in when providing the correct API key', async () => {
|
||||
await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, apiKey.secret);
|
||||
});
|
||||
});
|
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { api } from '@test/api';
|
||||
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import ServerInfo from 'src/commands/server-info';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`server-info (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
const consoleSpy = spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should show server version', async () => {
|
||||
await new ServerInfo(CLI_BASE_OPTIONS).run();
|
||||
|
||||
expect(consoleSpy.mock.calls).toEqual([
|
||||
[expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))],
|
||||
[expect.stringMatching('Supported image types: .*')],
|
||||
[expect.stringMatching('Supported video types: .*')],
|
||||
['Images: 0, Videos: 0, Total: 0'],
|
||||
]);
|
||||
});
|
||||
});
|
46
cli/test/e2e/upload.e2e-spec.ts
Normal file
46
cli/test/e2e/upload.e2e-spec.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { api } from '@test/api';
|
||||
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import Upload from 'src/commands/upload';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`upload (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should upload a folder recursively', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
|
||||
expect(assets.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('should create album from folder name', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
});
|
||||
});
|
3
cli/test/global-setup.js
Normal file
3
cli/test/global-setup.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
|
@ -8,7 +8,7 @@
|
|||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"target": "es2021",
|
||||
"moduleResolution": "node16",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
|
@ -17,8 +17,14 @@
|
|||
"esModuleInterop": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@test": ["test"],
|
||||
"@test/*": ["test/*"]
|
||||
"@test": ["../server/test"],
|
||||
"@test/*": ["../server/test/*"],
|
||||
"@app/immich": ["../server/src/immich"],
|
||||
"@app/immich/*": ["../server/src/immich/*"],
|
||||
"@app/infra": ["../server/src/infra"],
|
||||
"@app/infra/*": ["../server/src/infra/*"],
|
||||
"@app/domain": ["../server/src/domain"],
|
||||
"@app/domain/*": ["../server/src/domain/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["dist", "node_modules", "upload"]
|
||||
|
|
|
@ -20,4 +20,9 @@ export const albumApi = {
|
|||
expect(res.status).toEqual(200);
|
||||
return res.body as AlbumResponseDto;
|
||||
},
|
||||
getAllAlbums: async (server: any, accessToken: string) => {
|
||||
const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
|
||||
expect(res.status).toEqual(200);
|
||||
return res.body as AlbumResponseDto[];
|
||||
},
|
||||
};
|
||||
|
|
16
server/test/api/api-key-api.ts
Normal file
16
server/test/api/api-key-api.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import { apiKeyCreateStub } from '@test';
|
||||
import request from 'supertest';
|
||||
|
||||
export const apiKeyApi = {
|
||||
createApiKey: async (server: any, accessToken: string) => {
|
||||
const { status, body } = await request(server)
|
||||
.post('/api-key')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(apiKeyCreateStub);
|
||||
|
||||
expect(status).toBe(201);
|
||||
|
||||
return body as APIKeyCreateResponseDto;
|
||||
},
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import { activityApi } from './activity-api';
|
||||
import { albumApi } from './album-api';
|
||||
import { apiKeyApi } from './api-key-api';
|
||||
import { assetApi } from './asset-api';
|
||||
import { authApi } from './auth-api';
|
||||
import { libraryApi } from './library-api';
|
||||
|
@ -10,6 +11,7 @@ import { userApi } from './user-api';
|
|||
export const api = {
|
||||
activityApi,
|
||||
authApi,
|
||||
apiKeyApi,
|
||||
assetApi,
|
||||
libraryApi,
|
||||
sharedLinkApi,
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
name: "immich-test-e2e"
|
||||
name: 'immich-test-e2e'
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
image: immich-server-dev:latest
|
||||
build:
|
||||
context: ../
|
||||
context: ../../
|
||||
dockerfile: server/Dockerfile
|
||||
target: dev
|
||||
command: npm run test:e2e
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
- DB_HOSTNAME=database
|
|
@ -15,7 +15,7 @@ describe(`${ActivityController.name} (e2e)`, () => {
|
|||
let nonOwner: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
|
|
|
@ -24,7 +24,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
|
|||
let user2Albums: AlbumResponseDto[];
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -83,7 +83,8 @@ describe(`${AssetController.name} (e2e)`, () => {
|
|||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
[server, app] = await testApp.create();
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||
|
||||
await testApp.reset();
|
||||
|
|
|
@ -39,8 +39,7 @@ describe(`${AuthController.name} (e2e)`, () => {
|
|||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testApp.reset();
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -90,10 +90,7 @@ describe(`Supported file formats (e2e)`, () => {
|
|||
iso: 20,
|
||||
focalLength: 3.99,
|
||||
fNumber: 1.8,
|
||||
state: 'Douglas County, Nebraska',
|
||||
timeZone: 'America/Chicago',
|
||||
city: 'Ralston',
|
||||
country: 'United States of America',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -168,7 +165,7 @@ describe(`Supported file formats (e2e)`, () => {
|
|||
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create({ jobs: true });
|
||||
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
8
server/test/e2e/immich-e2e-config.json
Normal file
8
server/test/e2e/immich-e2e-config.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"reverseGeocoding": {
|
||||
"enabled": false
|
||||
},
|
||||
"machineLearning": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create({ jobs: true });
|
||||
server = (await testApp.create({ jobs: true })).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -8,7 +8,7 @@ describe(`${OAuthController.name} (e2e)`, () => {
|
|||
let server: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -12,7 +12,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
|
|||
let user3: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
|
|
|
@ -17,7 +17,8 @@ describe(`${PersonController.name}`, () => {
|
|||
let hiddenPerson: PersonEntity;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server, app] = await testApp.create();
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
personRepository = app.get<IPersonRepository>(IPersonRepository);
|
||||
});
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
|||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
|
@ -74,10 +74,10 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
|
|||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
clipEncode: false,
|
||||
configFile: false,
|
||||
configFile: true,
|
||||
facialRecognition: false,
|
||||
map: true,
|
||||
reverseGeocoding: true,
|
||||
reverseGeocoding: false,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
passwordLogin: true,
|
||||
|
|
|
@ -48,7 +48,7 @@ export default async () => {
|
|||
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.TYPESENSE_ENABLED = 'false';
|
||||
process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false';
|
||||
process.env.IMMICH_TEST_ENV = 'true';
|
||||
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/immich-e2e-config.json`);
|
||||
process.env.TZ = 'Z';
|
||||
};
|
||||
|
|
|
@ -33,7 +33,8 @@ describe(`${SharedLinkController.name} (e2e)`, () => {
|
|||
let app: INestApplication<any>;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server, app] = await testApp.create();
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||
|
||||
await testApp.reset();
|
||||
|
|
|
@ -11,7 +11,7 @@ describe(`${SystemConfigController.name} (e2e)`, () => {
|
|||
let nonAdmin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server] = await testApp.create();
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
|
|
|
@ -18,7 +18,8 @@ describe(`${UserController.name}`, () => {
|
|||
let userRepository: Repository<UserEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
[server, app] = await testApp.create();
|
||||
app = await testApp.create();
|
||||
server = app.getHttpServer();
|
||||
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
|
||||
});
|
||||
|
||||
|
|
4
server/test/fixtures/api-key.stub.ts
vendored
4
server/test/fixtures/api-key.stub.ts
vendored
|
@ -11,3 +11,7 @@ export const keyStub = {
|
|||
user: userStub.admin,
|
||||
} as APIKeyEntity),
|
||||
};
|
||||
|
||||
export const apiKeyCreateStub = {
|
||||
name: 'API Key',
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import { INestApplication } from '@nestjs/common';
|
|||
import { Test } from '@nestjs/testing';
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Server } from 'tls';
|
||||
import { EntityTarget, ObjectLiteral } from 'typeorm';
|
||||
import { AppService } from '../src/microservices/app.service';
|
||||
|
||||
|
@ -58,7 +59,7 @@ interface TestAppOptions {
|
|||
let app: INestApplication;
|
||||
|
||||
export const testApp = {
|
||||
create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => {
|
||||
create: async (options?: TestAppOptions): Promise<INestApplication> => {
|
||||
const { jobs } = options || { jobs: false };
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
|
||||
|
@ -80,20 +81,27 @@ export const testApp = {
|
|||
.compile();
|
||||
|
||||
app = await moduleFixture.createNestApplication().init();
|
||||
await app.listen(0);
|
||||
|
||||
if (jobs) {
|
||||
await app.get(AppService).init();
|
||||
}
|
||||
|
||||
return [app.getHttpServer(), app];
|
||||
const port = app.getHttpServer().address().port;
|
||||
const protocol = app instanceof Server ? 'https' : 'http';
|
||||
process.env.IMMICH_INSTANCE_URL = protocol + '://127.0.0.1:' + port;
|
||||
|
||||
return app;
|
||||
},
|
||||
reset: async (options?: ResetOptions) => {
|
||||
await db.reset(options);
|
||||
},
|
||||
teardown: async () => {
|
||||
if (app) {
|
||||
await app.get(AppService).teardown();
|
||||
await db.disconnect();
|
||||
await app.close();
|
||||
}
|
||||
await db.disconnect();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue