1
0
forked from Cutlery/immich

Compare commits

..

67 Commits

Author SHA1 Message Date
mertalev 80c9f29a41 formatting 2024-03-05 23:41:57 -05:00
mertalev 4410e31523 updated tests 2024-03-05 22:45:35 -05:00
mertalev c5d19b3280 use original 2024-03-05 22:45:35 -05:00
mertalev 749f4351df crop before resize 2024-03-05 22:45:35 -05:00
mertalev b34dd39978 do crop and resize together 2024-03-05 22:45:35 -05:00
Alex 2f53f6a62c feat(web): search by filename (#7624)
* Toggle to search by filename

* wild card search and pr feedback

* Pr feedback

* naming

* placeholder

* Create index

* pr feedback

* pr feedback

* Update web/src/lib/components/shared-components/search-bar/search-text-section.svelte

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* pr feedback

* pr feedback

* pr feedback

* pr feedback

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-05 17:08:35 -06:00
Jonathan Jogenfors ae46188753 chore(deps): bump sanitize-html, fixing CVE-2024-21501 (#7662)
* bump sanitize-html

* bump better
2024-03-05 17:35:52 -05:00
renovate[bot] 51f6b8f23b chore(deps): update dependency @types/cookie-parser to v1.4.7 (#7661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 17:31:26 -05:00
Jonathan Jogenfors 5d377e5b0f chore(server): eslint await-thenable (#7545)
* await-thenable

* fix library watchers

* moar eslint

* fix test

* fix typo

* try to remove check void return

* fix checksVoidReturn

* move to domain utils

* remove eslint ignores

* chore: cleanup types

* chore: use logger

* fix: e2e

---------

Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-05 17:23:06 -05:00
Jason Rasmussen 972d5a3411 feat(server): deterministic download order (#7658) 2024-03-05 15:04:43 -06:00
renovate[bot] 8df63b7c94 fix(deps): update dependency archiver to v7 (#7622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 12:26:54 -05:00
renovate[bot] ee3b2a0cf5 chore(deps): update server (#7652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 12:16:41 -05:00
Jason Rasmussen 8988d3f886 chore: download e2e (#7651) 2024-03-05 12:07:46 -05:00
bo0tzz 4dc0fc45e7 feat(cli): Use well-known endpoint to resolve API (#6733)
* feat(cli): use immich-well-known

* chore: e2e test

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-05 12:06:24 -05:00
renovate[bot] 1c93ef1916 chore(deps): update dependency svelte-check to v3.6.6 (#7650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 17:01:12 +00:00
renovate[bot] 9bf1d87e35 chore(deps): update dependency @types/node to v20.11.24 (#7649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 16:30:51 +00:00
renovate[bot] 31b823058d chore(deps): update dependency @types/node to v20.11.24 (#7645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 11:29:04 -05:00
Jason Rasmussen 9a2e0e8962 chore(cli): remove unused packages (#7648) 2024-03-05 16:18:53 +00:00
renovate[bot] e0ae936496 chore(deps): update dependency @types/node to v20.11.24 (#7646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 15:58:40 +00:00
Alex 0675389aae Revert "fix(web): prevent duplicate calls to time bucket endpoint" (#7644)
Revert "fix(web): prevent duplicate calls to time bucket endpoint (#7563)"

This reverts commit 8b02f18e99.
2024-03-05 09:44:33 -06:00
Michel Heusschen facd0bc3a4 Revert "perf(web): optimize date groups" (#7638)
Revert "perf(web): optimize date groups (#7593)"

This reverts commit 762c4684f8.
2024-03-05 09:43:24 -06:00
renovate[bot] 967019d9e0 chore(deps): update dependency @types/node to v20.11.23 (#7641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 07:57:22 -05:00
renovate[bot] e5da735918 chore(deps): update @immich/cli (#7640)
chore(deps): update dependency @types/node to v20.11.23

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 07:57:03 -05:00
waclaw66 9b3f60ffde fix(web): prettify album download filename (#7637)
fix(server): pretify download filename
2024-03-05 07:56:12 -05:00
renovate[bot] a5d19bc945 fix(deps): update server (#7635)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-05 03:48:00 +00:00
renovate[bot] 9995647d63 chore(deps): update dependency prettier-plugin-svelte to v3.2.2 (#7633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 22:43:55 -05:00
Nicholas Flamy 70881bc97f Update truenas.md with permission info. (#7606)
* Update truenas.md with permission info.

This is very important information about permissions on datasets used by immich.

* Update truenas.md

* Update truenas.md with proper formatting
2024-03-05 02:51:20 +00:00
renovate[bot] dbf0ddf3a7 chore(deps): update @immich/cli (#7629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 21:36:53 -05:00
renovate[bot] 2e62e3a0ca chore(deps): update dependency @types/node to v20.11.22 (#7630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 21:36:01 -05:00
martin 6ab404597c fix(server): incorrect number of assets for a person (#7602)
* fix: incorrect number of assets

* fix: tests

* pr feedback

* fix: e2e test

* fix: e2e test

* fix: e2e test

* feat: more tests
2024-03-04 18:11:54 -05:00
renovate[bot] 5bc13c49a4 chore(deps): update dependency @types/node to v20.11.22 (#7610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-04 17:42:26 -05:00
martin 935ddf3fbd fix(server, web): prevent reload when liking an asset (#7589)
pr feedback
2024-03-04 17:41:10 -05:00
Sam Holton 87c3d886ff feat(web): use lib/utils/copyToClipboard for share link (#7603) 2024-03-04 17:39:58 -05:00
Robert Vollmer de71d8e0a3 fix(server): regular version check (#7620)
`dt.diffNow()` equals `dt.diff(DateTime.now())`, so it returns a
negative number when `dt` is in the past (which it always is in this
case).

Therefore we could only get over the condition during startup (when
`this.releaseVersionCheckedAt` isn't set yet), effectively breaking
update notifications while the server is running.
2024-03-04 08:42:22 -06:00
Sam Holton 7ef202c8b2 feat(server, web): add checkbox to create user screen for shouldChang… (#7598)
feat(server, web): add checkbox to create user screen for shouldChangePassword
2024-03-03 23:40:03 -06:00
DawidPietrykowski e8b001f62f feat: preloading of machine learning models (#7540) 2024-03-03 19:48:56 -05:00
Mert 762c4684f8 perf(web): optimize date groups (#7593)
* optimize date groups

* remove `.values()`

* remove console.log

* remove if condition

* remove console.log

* remove outdated comment

* revert dynamic import
2024-03-03 16:12:52 -06:00
Mert 2fa10a254c feat(web): improve alt text (#7596)
* alt text

* memory lane alt text

* revert sql generator change

* use getAltText

* oops

* handle large number of people in asset

* nit

* add aria-label to search button

* update api

* fixed tests

* fixed typing

* fixed spacing

* fix displaying null
2024-03-03 16:42:17 -05:00
martin 07c926bb12 fix: bump version pipeline (#7586)
* fix: bump version pipeline

* pr feedback
2024-03-03 15:17:21 -06:00
renovate[bot] 3d410ff7dc chore(deps): update dependency @playwright/test to v1.42.0 (#7601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-03 15:16:03 -06:00
Sam Holton 2bb7b3e60f feat(web): add share link on asset-viewer (#7595)
* feat(web): add share link on asset-viewer

* PR feedback: move download to context, make share first button
2024-03-03 15:15:35 -06:00
Sam Holton 29a4389aac feat(web): show user quota on server stats page (#7591) 2024-03-03 15:47:00 -05:00
martin 8ce18b3403 feat(machine-learning): support cuda 12 (#7569)
* feat: support cuda12

* fix: group optional

* move to cuda 12

* pr feedback
2024-03-02 23:36:16 -05:00
renovate[bot] fd3503e77d chore(deps): update dependency @types/pg to v8.11.2 (#7585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-02 18:08:41 -05:00
martin ebe7a14c14 fix(server): prevent leaking isFavorite and isArchived info (#7580)
* fix: prevent leaking favorites info

* add e2e test

* fix: e2e test

* fix: isArchived

* fix: keep old version
2024-03-02 18:01:24 -05:00
Sam Holton f03381a5b1 feat(server): allow oauth claim to set 0 for no quota (#7581)
* feat(server): allow oauth claim to set 0 for no quota

* PR feedback to remove extra objects from user.stub.ts
2024-03-02 14:18:56 -06:00
martin 8d44afe915 feat(web): ascending order for slideshow (#7502)
* feat: ascending order for slideshow

* feat: use dropdown

* rename

* fix: size

* pr feedback

* fix: hide text on small screen

* Wording

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-03-02 09:50:02 -06:00
martin db455060f0 fix(web): show history search box only when needed (#7544)
show history search box only when needed
2024-03-02 09:38:34 -06:00
Ignacy Kajdan b63b42d3d7 docs: fix the database name env variable (#7576) 2024-03-02 08:58:07 -05:00
Michel Heusschen a4e6c43823 perf(web): asset delete (#7555)
* perf(web): asset delete

* update asset delete on search page

* don't use arrow function in class
2024-03-01 19:49:31 -05:00
Sam Holton 7303fab9d9 feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim (#7548)
* feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim

* feat(server/web): fix format and use domain.util constants

* address some pr feedback

* simplify oauth storage quota logic

* adding tests and pr feedback

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-01 19:46:07 -05:00
Mert 8b02f18e99 fix(web): prevent duplicate calls to time bucket endpoint (#7563) 2024-03-01 14:16:07 -05:00
waclaw66 670a3838a3 fix(mobile): bottom bar Upload translation (#7553)
Co-authored-by: Václav Nováček <waclaw@waclaw.cz>
2024-03-01 11:24:55 -06:00
Simon Séhier 3e06062974 fix(immich-admin): only 1st argument was passed (#7552) 2024-03-01 07:34:59 -05:00
martin 3b772a772c fix(web): immich version (#7541)
* fix: web version

* update package-lock.json

* update typescript-sdk
2024-03-01 01:26:50 -06:00
Ben McCann 55ecfafa82 chore(web): fix eslint setup in VS Code (#7543) 2024-02-29 19:28:54 -05:00
Michel Heusschen c89d91e006 feat: filter people when using smart search (#7521) 2024-02-29 16:14:48 -05:00
Jason Rasmussen 15a4a4aaaa chore: remove unused upload property (#7535)
* chore: remove isExternal

* chore: open-api
2024-02-29 16:02:08 -05:00
Jason Rasmussen 3d25d91e77 refactor: library e2e (#7538)
* refactor: library e2e

* refactor: remove before each usages
2024-02-29 15:10:08 -05:00
Jonathan Jogenfors efa6efd200 feat(server,web): remove external path nonsense and make libraries admin-only (#7237)
* remove external path

* open-api

* make sql

* move library settings to admin panel

* Add documentation

* show external libraries only

* fix library list

* make user library settings look good

* fix test

* fix tests

* fix tests

* can pick user for library

* fix tests

* fix e2e

* chore: make sql

* Use unauth exception

* delete user library list

* cleanup

* fix e2e

* fix await lint

* chore: remove unused code

* chore: cleanup

* revert docs

* fix: is admin stuff

* table alignment

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-29 12:35:37 -06:00
Michel Heusschen 369acc7bea fix(web): asset disappears from album after metadata edit (#7520) 2024-02-29 11:44:30 -06:00
Jason Rasmussen 100363c7be refactor(e2e): use better dummy assets (#7536) 2024-02-29 12:07:01 -05:00
Jason Rasmussen af0de1a768 chore: linting (#7532)
* chore: linting

* fix: broken tests

* fix: formatting
2024-02-29 11:26:55 -05:00
Jason Rasmussen 09a7291527 refactor(web): drop axios (#7490)
* refactor: downloadApi

* refactor: assetApi

* chore: drop axios

* chore: tidy up

* chore: fix exports

* fix: show notification when download starts
2024-02-29 11:22:39 -05:00
Jason Rasmussen bb3d81bfc5 chore: build tweaks (#7484) 2024-02-29 09:22:25 -05:00
dependabot[bot] f1331905f0 chore(deps): bump tj-actions/verify-changed-files from 18 to 19 (#7524)
Bumps [tj-actions/verify-changed-files](https://github.com/tj-actions/verify-changed-files) from 18 to 19.
- [Release notes](https://github.com/tj-actions/verify-changed-files/releases)
- [Changelog](https://github.com/tj-actions/verify-changed-files/blob/main/HISTORY.md)
- [Commits](https://github.com/tj-actions/verify-changed-files/compare/v18...v19)

---
updated-dependencies:
- dependency-name: tj-actions/verify-changed-files
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-29 07:56:20 -05:00
renovate[bot] 7eb8e2ff9c chore(deps): update dependency @types/pg to v8.11.1 (#7522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-29 07:54:48 -05:00
219 changed files with 5657 additions and 24860 deletions
+13 -12
View File
@@ -1,30 +1,31 @@
.vscode/
.github/
.git/
design/
docker/
docs/
e2e/
fastlane/
machine-learning/
misc/
mobile/
server/node_modules/
cli/coverage/
cli/dist/
cli/node_modules/
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/.reverse-geocoding-dump/
server/node_modules/
server/upload/
server/dist/
server/www/
server/test/assets/
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
cli/node_modules/
cli/.reverse-geocoding-dump/
cli/upload/
cli/dist/
e2e/
open-api/typescript-sdk/node_modules/
open-api/typescript-sdk/build/
+1 -1
View File
@@ -16,4 +16,4 @@ max_line_length = off
trim_trailing_whitespace = false
[*.{yml,yaml}]
quote_type = double
quote_type = single
-2
View File
@@ -8,8 +8,6 @@ mobile/openapi/.openapi-generator/FILES linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true
open-api/typescript-sdk/axios-client/**/* -diff -merge
open-api/typescript-sdk/axios-client/**/* linguist-generated=true
open-api/typescript-sdk/fetch-client.ts -diff -merge
open-api/typescript-sdk/fetch-client.ts linguist-generated=true
+24 -9
View File
@@ -35,7 +35,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
submodules: 'recursive'
- name: Run e2e tests
run: make server-e2e-jobs
@@ -184,7 +184,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
submodules: 'recursive'
- name: Setup Node
uses: actions/setup-node@v4
@@ -194,25 +194,40 @@ jobs:
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup cli
run: npm ci && npm run build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: npm run test
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
run: npx playwright test
if: ${{ !cancelled() }}
mobile-unit-tests:
name: Mobile
@@ -222,8 +237,8 @@ jobs:
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.16.9"
channel: 'stable'
flutter-version: '3.16.9'
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@@ -241,7 +256,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: "poetry"
cache: 'poetry'
- name: Install dependencies
run: |
poetry install --with dev --with cpu
@@ -279,7 +294,7 @@ jobs:
- name: Run API generation
run: make open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-files
with:
files: |
@@ -334,7 +349,7 @@ jobs:
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-files
with:
files: |
@@ -352,7 +367,7 @@ jobs:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v18
uses: tj-actions/verify-changed-files@v19
id: verify-changed-sql-files
with:
files: |
+158 -1927
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -14,7 +14,6 @@
],
"devDependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
+25 -8
View File
@@ -3,6 +3,7 @@ import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/p
import path from 'node:path';
import yaml from 'yaml';
import { ImmichApi } from './api.service';
class LoginError extends Error {
constructor(message: string) {
super(message);
@@ -14,14 +15,12 @@ class LoginError extends Error {
}
export class SessionService {
readonly configDirectory!: string;
readonly authPath!: string;
constructor(configDirectory: string) {
this.configDirectory = configDirectory;
this.authPath = path.join(configDirectory, '/auth.yml');
private get authPath() {
return path.join(this.configDirectory, '/auth.yml');
}
constructor(private configDirectory: string) {}
async connect(): Promise<ImmichApi> {
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY;
@@ -48,6 +47,8 @@ export class SessionService {
}
}
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
const api = new ImmichApi(instanceUrl, apiKey);
const pingResponse = await api.pingServer().catch((error) => {
@@ -62,7 +63,9 @@ export class SessionService {
}
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
console.log('Logging in...');
console.log(`Logging in to ${instanceUrl}`);
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
const api = new ImmichApi(instanceUrl, apiKey);
@@ -83,7 +86,7 @@ export class SessionService {
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
console.log('Wrote auth info to ' + this.authPath);
console.log(`Wrote auth info to ${this.authPath}`);
return api;
}
@@ -98,4 +101,18 @@ export class SessionService {
console.log('Successfully logged out');
}
private async resolveApiEndpoint(instanceUrl: string): Promise<string> {
const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
try {
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
if (endpoint !== instanceUrl) {
console.debug(`Discovered API at ${endpoint}`);
}
return endpoint;
} catch {
return instanceUrl;
}
}
}
+4 -4
View File
@@ -2,7 +2,7 @@
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: "3.8"
version: '3.8'
name: immich-dev
@@ -30,7 +30,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: [ "/usr/src/app/bin/immich-dev", "immich" ]
command: ['/usr/src/app/bin/immich-dev', 'immich']
<<: *server-common
ports:
- 3001:3001
@@ -41,7 +41,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: [ "/usr/src/app/bin/immich-dev", "microservices" ]
command: ['/usr/src/app/bin/immich-dev', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
@@ -57,7 +57,7 @@ services:
image: immich-web-dev:latest
build:
context: ../web
command: [ "/usr/src/app/bin/immich-web" ]
command: ['/usr/src/app/bin/immich-web']
env_file:
- .env
ports:
+3 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
name: immich-prod
@@ -17,7 +17,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: [ "start.sh", "immich" ]
command: ['start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
@@ -27,7 +27,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: [ "start.sh", "microservices" ]
command: ['start.sh', 'microservices']
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
+3 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
@@ -14,7 +14,7 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
command: ['start.sh', 'immich']
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
@@ -33,7 +33,7 @@ services:
# extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/hardware-transcoding
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
command: [ "start.sh", "microservices" ]
command: ['start.sh', 'microservices']
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
+3 -1
View File
@@ -67,9 +67,11 @@ Once you have a new OAuth client application configured, Immich can be configure
| Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |
+6 -3
View File
@@ -95,13 +95,16 @@ The default configuration looks like this:
"issuerUrl": "",
"clientId": "",
"clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota",
"defaultStorageQuota": 0,
"buttonText": "Login with OAuth",
"autoRegister": true,
"autoLaunch": false
"autoLaunch": false,
"mobileOverrideEnabled": false,
"mobileRedirectUri": ""
},
"passwordLogin": {
"enabled": true
+13 -11
View File
@@ -67,7 +67,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `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_DATABASE_NAME` | 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
@@ -124,16 +124,18 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Services |
| :----------------------------------------------- | :----------------------------------------------------------------- | :-----------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning |
| Variable | Description | Default | Services |
| :----------------------------------------------- | :------------------------------------------------------------------- | :-----------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
+4
View File
@@ -28,6 +28,10 @@ Or before beginning app installation, [create the datasets](https://www.truenas.
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
:::info Permissions
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
:::
## Installing the Immich Application
To install the **Immich** application, go to **Apps**, click **Discover Apps**, either begin typing Immich into the search field or scroll down to locate the **Immich** application widget.
+4 -4
View File
@@ -12231,7 +12231,7 @@
"mime-format": "2.0.0",
"mime-types": "2.1.27",
"postman-url-encoder": "2.1.3",
"sanitize-html": "^2.11.0",
"sanitize-html": "^2.12.1",
"semver": "^7.5.4",
"uuid": "3.4.0"
}
@@ -14762,9 +14762,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sanitize-html": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz",
"integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
+31
View File
@@ -0,0 +1,31 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'],
root: true,
env: {
node: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
},
};
+16
View File
@@ -0,0 +1,16 @@
.DS_Store
node_modules
/build
/package
.env
.env.*
!.env.example
*.md
*.json
coverage
dist
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"plugins": ["prettier-plugin-organize-imports"]
}
+4 -3
View File
@@ -1,4 +1,4 @@
version: "3.8"
version: '3.8'
name: immich-e2e
@@ -16,6 +16,7 @@ x-server-build: &server-common
- IMMICH_MACHINE_LEARNING_ENABLED=false
volumes:
- upload:/usr/src/app/upload
- ../server/test/assets:/data/assets
depends_on:
- redis
- database
@@ -23,14 +24,14 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich-e2e-server
command: [ "./start.sh", "immich" ]
command: ['./start.sh', 'immich']
<<: *server-common
ports:
- 2283:3001
immich-microservices:
container_name: immich-e2e-microservices
command: [ "./start.sh", "microservices" ]
command: ['./start.sh', 'microservices']
<<: *server-common
redis:
+2223 -26
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -7,7 +7,11 @@
"scripts": {
"test": "vitest --config vitest.config.ts",
"test:web": "npx playwright test",
"start:web": "npx playwright test --ui"
"start:web": "npx playwright test --ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix"
},
"keywords": [],
"author": "",
@@ -19,11 +23,21 @@
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.1",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"socket.io-client": "^4.7.4",
"supertest": "^6.3.4",
"typescript": "^5.3.3",
+11 -37
View File
@@ -20,10 +20,7 @@ describe('/activity', () => {
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
create(
{ activityCreateDto: dto },
{ headers: asBearerAuth(accessToken || admin.accessToken) },
);
create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) });
beforeAll(async () => {
apiUtils.setup();
@@ -56,13 +53,9 @@ describe('/activity', () => {
});
it('should require an albumId', async () => {
const { status, body } = await request(app)
.get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/activity').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid albumId', async () => {
@@ -71,9 +64,7 @@ describe('/activity', () => {
.query({ albumId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid assetId', async () => {
@@ -82,9 +73,7 @@ describe('/activity', () => {
.query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
});
it('should start off empty', async () => {
@@ -160,9 +149,7 @@ describe('/activity', () => {
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const response1 = await request(app)
.get('/activity')
@@ -215,9 +202,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.invalid });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should require a comment when type is comment', async () => {
@@ -226,12 +211,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidDto.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest([
'comment must be a string',
'comment should not be empty',
]),
);
expect(body).toEqual(errorDto.badRequest(['comment must be a string', 'comment should not be empty']));
});
it('should add a comment to an album', async () => {
@@ -271,9 +251,7 @@ describe('/activity', () => {
});
it('should return a 200 for a duplicate like on the album', async () => {
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const { status, body } = await request(app)
.post('/activity')
@@ -356,9 +334,7 @@ describe('/activity', () => {
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/activity/${uuidDto.notFound}`,
);
const { status, body } = await request(app).delete(`/activity/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -420,9 +396,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no activity.delete access'),
);
expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
});
it('should let a non-owner remove their own comment', async () => {
+26 -38
View File
@@ -41,7 +41,7 @@ describe('/album', () => {
]);
[user1Asset1, user1Asset2] = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken, { isFavorite: true }),
apiUtils.createAsset(user1.accessToken),
]);
@@ -93,10 +93,7 @@ describe('/album', () => {
}),
]);
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) },
);
await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /album', () => {
@@ -111,9 +108,7 @@ describe('/album', () => {
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(['shared must be a boolean value']),
);
expect(body).toEqual(errorDto.badRequest(['shared must be a boolean value']));
});
it('should reject an invalid assetId param', async () => {
@@ -124,6 +119,17 @@ describe('/album', () => {
expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID']));
});
it("should not show other users' favorites", async () => {
const { status, body } = await request(app)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining({ isFavorite: false })],
});
});
it('should not return shared albums with a deleted owner', async () => {
const { status, body } = await request(app)
.get('/album?shared=true')
@@ -153,9 +159,7 @@ describe('/album', () => {
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app)
.get('/album')
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
@@ -250,9 +254,7 @@ describe('/album', () => {
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/album/${user1Albums[0].id}`,
);
const { status, body } = await request(app).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -265,7 +267,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
});
});
@@ -277,7 +279,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [expect.objectContaining(user2Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user2Albums[0].assets[0].id })],
});
});
@@ -289,7 +291,7 @@ describe('/album', () => {
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [expect.objectContaining(user1Albums[0].assets[0])],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
});
});
@@ -326,9 +328,7 @@ describe('/album', () => {
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/album')
.send({ albumName: 'New album' });
const { status, body } = await request(app).post('/album').send({ albumName: 'New album' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -360,9 +360,7 @@ describe('/album', () => {
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/album/${user1Albums[0].id}/assets`,
);
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/assets`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -375,9 +373,7 @@ describe('/album', () => {
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
it('should be able to add own asset to shared album', async () => {
@@ -388,9 +384,7 @@ describe('/album', () => {
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: asset.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
});
@@ -473,9 +467,7 @@ describe('/album', () => {
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
@@ -485,9 +477,7 @@ describe('/album', () => {
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user1Asset1.id, success: true }),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
});
@@ -501,9 +491,7 @@ describe('/album', () => {
});
it('should require authentication', async () => {
const { status, body } = await request(app)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/users`).send({ sharedUserIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
+51 -113
View File
@@ -7,27 +7,18 @@ import {
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import {
apiUtils,
app,
dbUtils,
tempDir,
testAssetDir,
wsUtils,
} from 'src/utils';
import { apiUtils, app, dbUtils, fileUtils, tempDir, testAssetDir, wsUtils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const sha1 = (bytes: Buffer) =>
createHash('sha1').update(bytes).digest('base64');
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
@@ -65,50 +56,45 @@ describe('/asset', () => {
]);
// asset location
assetLocation = await apiUtils.createAsset(
admin.accessToken,
{},
{
assetLocation = await apiUtils.createAsset(admin.accessToken, {
assetData: {
filename: 'thompson-springs.jpg',
bytes: await readFile(locationAssetFilepath),
},
);
});
await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
user1Assets = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(
user1.accessToken,
{
isFavorite: true,
isExternal: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(user1.accessToken, {
isFavorite: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
for (const asset of [...user1Assets, ...user2Assets]) {
expect(asset.duplicate).toBe(false);
}
await Promise.all([
// stats
apiUtils.createAsset(userStats.accessToken),
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
apiUtils.createAsset(userStats.accessToken, { isArchived: true }),
apiUtils.createAsset(
userStats.accessToken,
{
isArchived: true,
isFavorite: true,
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(userStats.accessToken, {
isArchived: true,
isFavorite: true,
assetData: { filename: 'example.mp4' },
}),
]);
const person1 = await apiUtils.createPerson(user1.accessToken, {
@@ -126,9 +112,7 @@ describe('/asset', () => {
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/${uuidDto.notFound}`,
);
const { status, body } = await request(app).get(`/asset/${uuidDto.notFound}`);
expect(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401);
});
@@ -163,9 +147,7 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id],
});
const { status, body } = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
const { status, body } = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
});
@@ -195,9 +177,7 @@ describe('/asset', () => {
assetIds: [user1Assets[0].id],
});
const data = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
@@ -280,7 +260,7 @@ describe('/asset', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it.each(Array(10))('should return 1 random assets', async () => {
it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -290,14 +270,9 @@ describe('/asset', () => {
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
// assets owned by user2
expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -309,24 +284,18 @@ describe('/asset', () => {
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
// assets owned by user2
expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
}
});
it.each(Array(10))(
it.each(TEN_TIMES)(
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => {
const { status, body } = await request(app)
.get('/[]asset/random')
.get('/asset/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user2Assets[0].id }),
]);
expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]);
},
);
@@ -341,9 +310,7 @@ describe('/asset', () => {
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/asset/:${uuidDto.notFound}`,
);
const { status, body } = await request(app).put(`/asset/:${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -365,10 +332,7 @@ describe('/asset', () => {
});
it('should favorite an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
expect(before.isFavorite).toBe(false);
const { status, body } = await request(app)
@@ -380,10 +344,7 @@ describe('/asset', () => {
});
it('should archive an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id);
expect(before.isArchived).toBe(false);
const { status, body } = await request(app)
@@ -497,9 +458,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['each value in ids must be a UUID']),
);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should throw an error when the id is not found', async () => {
@@ -509,9 +468,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no asset.delete access'),
);
expect(body).toEqual(errorDto.badRequest('Not found or no asset.delete access'));
});
it('should move an asset to the trash', async () => {
@@ -647,11 +604,9 @@ describe('/asset', () => {
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
const filepath = join(testAssetDir, input);
const { id, duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{ bytes: await readFile(filepath), filename: basename(filepath) },
);
const { id, duplicate } = await apiUtils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
expect(duplicate).toBe(false);
@@ -667,14 +622,12 @@ describe('/asset', () => {
it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{
const { duplicate } = await apiUtils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
});
expect(duplicate).toBe(true);
});
@@ -701,29 +654,21 @@ describe('/asset', () => {
for (const { filepath, checksum } of motionTests) {
it(`should extract motionphoto video from ${filepath}`, async () => {
const response = await apiUtils.createAsset(
admin.accessToken,
{},
{
const response = await apiUtils.createAsset(admin.accessToken, {
assetData: {
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
});
await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
expect(response.duplicate).toBe(false);
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
response.id,
);
const asset = await apiUtils.getAssetInfo(admin.accessToken, response.id);
expect(asset.livePhotoVideoId).toBeDefined();
const video = await apiUtils.getAssetInfo(
admin.accessToken,
asset.livePhotoVideoId as string,
);
const video = await apiUtils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string);
expect(video.checksum).toStrictEqual(checksum);
});
}
@@ -731,9 +676,7 @@ describe('/asset', () => {
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -775,9 +718,7 @@ describe('/asset', () => {
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -792,14 +733,11 @@ describe('/asset', () => {
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
assetLocation.id,
);
const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id);
const original = await readFile(locationAssetFilepath);
const originalChecksum = sha1(original);
const downloadChecksum = sha1(body);
const originalChecksum = fileUtils.sha1(original);
const downloadChecksum = fileUtils.sha1(body);
expect(originalChecksum).toBe(downloadChecksum);
expect(downloadChecksum).toBe(asset.checksum);
+3 -11
View File
@@ -1,9 +1,4 @@
import {
deleteAssets,
getAuditFiles,
updateAsset,
type LoginResponseDto,
} from '@immich/sdk';
import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
@@ -20,17 +15,14 @@ describe('/audit', () => {
describe('GET :/file-report', () => {
it('excludes assets without issues from report', async () => {
const [trashedAsset, archivedAsset, _] = await Promise.all([
const [trashedAsset, archivedAsset] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) },
),
deleteAssets({ assetBulkDeleteDto: { ids: [trashedAsset.id] } }, { headers: asBearerAuth(admin.accessToken) }),
updateAsset(
{
id: archivedAsset.id,
+15 -51
View File
@@ -1,16 +1,6 @@
import {
LoginResponseDto,
getAuthDevices,
login,
signUpAdmin,
} from '@immich/sdk';
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import {
deviceDto,
errorDto,
loginResponseDto,
signupResponseDto,
} from 'src/responses';
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';
@@ -48,18 +38,14 @@ describe(`/auth/admin-sign-up`, () => {
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(data);
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);
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
@@ -86,9 +72,7 @@ describe(`/auth/admin-sign-up`, () => {
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);
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
@@ -107,9 +91,7 @@ describe('/auth/*', () => {
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app)
.post('/auth/login')
.send({ email, password: 'incorrect' });
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
@@ -125,9 +107,7 @@ describe('/auth/*', () => {
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app)
.post('/auth/login')
.send({ email, password });
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
@@ -136,15 +116,9 @@ describe('/auth/*', () => {
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;'
);
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;');
});
});
@@ -176,18 +150,12 @@ describe('/auth/*', () => {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(6);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app)
.delete(`/auth/devices`)
.set('Authorization', `Bearer ${admin.accessToken}`);
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);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
@@ -195,9 +163,7 @@ describe('/auth/*', () => {
.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')
);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
@@ -219,9 +185,7 @@ describe('/auth/*', () => {
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.set('Authorization', 'Bearer 123');
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
+42 -6
View File
@@ -1,18 +1,23 @@
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
import { readFile, writeFile } from 'node:fs/promises';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import { apiUtils, app, dbUtils, fileUtils, tempDir } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/download', () => {
let admin: LoginResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
asset1 = await apiUtils.createAsset(admin.accessToken);
[asset1, asset2] = await Promise.all([
apiUtils.createAsset(admin.accessToken),
apiUtils.createAsset(admin.accessToken),
]);
});
describe('POST /download/info', () => {
@@ -40,11 +45,42 @@ describe('/download', () => {
});
});
describe('POST /download/archive', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/download/archive`)
.send({ assetIds: [asset1.id, asset2.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download an archive', async () => {
const { status, body } = await request(app)
.post('/download/archive')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ assetIds: [asset1.id, asset2.id] });
expect(status).toBe(200);
expect(body instanceof Buffer).toBe(true);
await writeFile(`${tempDir}/archive.zip`, body);
await fileUtils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`);
const files = [
{ filename: 'example.png', id: asset1.id },
{ filename: 'example+1.png', id: asset2.id },
];
for (const { id, filename } of files) {
const bytes = await readFile(`${tempDir}/archive/${filename}`);
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
expect(fileUtils.sha1(bytes)).toBe(asset.checksum);
}
});
});
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/download/asset/${asset1.id}`,
);
const { status, body } = await request(app).post(`/download/asset/${asset1.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -56,7 +92,7 @@ describe('/download', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/jpeg');
expect(response.headers['content-type']).toEqual('image/png');
});
});
});
+456
View File
@@ -0,0 +1,456 @@
import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk';
import { userDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/library', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let library: LibraryResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
user = await apiUtils.userSetup(admin.accessToken, userDto.user1);
library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
});
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/library');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should start with a default upload library', async () => {
const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]),
);
});
});
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/library').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require admin authentication', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ type: LibraryType.External });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.External });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an external library with options', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
}),
);
});
it('should not create an external library with duplicate import paths', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path', '/path'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.External,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should create an upload library with defaults', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
});
});
describe('PUT /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/library/${uuidDto.notFound}`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should change the library name', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
name: 'New Library Name',
}),
);
});
it('should not set an empty name', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [testAssetDirInternal] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: [testAssetDirInternal],
}),
);
});
it('should reject an empty import path', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
});
it('should reject duplicate import paths', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
});
it('should change the exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
exclusionPatterns: ['**/Raw/**'],
}),
);
});
it('should reject duplicate exclusion patterns', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should reject an empty exclusion pattern', async () => {
const { status, body } = await request(app)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require admin access', async () => {
const { status, body } = await request(app)
.get(`/library/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should get library by id', async () => {
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
const { status, body } = await request(app)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
});
describe('DELETE /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not delete the last upload library', async () => {
const libraries = await getAllLibraries(
{ $type: LibraryType.Upload },
{ headers: asBearerAuth(admin.accessToken) },
);
const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId);
expect(adminLibraries.length).toBeGreaterThanOrEqual(1);
const lastLibrary = adminLibraries.pop() as LibraryResponseDto;
// delete all but the last upload library
for (const library of adminLibraries) {
const { status } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
}
const { status, body } = await request(app)
.delete(`/library/${lastLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(errorDto.noDeleteUploadLibrary);
expect(status).toBe(400);
});
it('should delete an external library', async () => {
const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External });
const { status, body } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
expect(body).toEqual({});
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/library/${uuidDto.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /library/:id/validate', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should pass with no import paths', async () => {
const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
});
it('should fail if path does not exist', async () => {
const pathToTest = `${testAssetDirInternal}/does/not/exist`;
const response = await apiUtils.validateLibrary(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 = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
const response = await apiUtils.validateLibrary(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 a directory`,
});
});
});
});
+2 -9
View File
@@ -15,16 +15,9 @@ describe(`/oauth`, () => {
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({});
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',
])
);
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});
+8 -26
View File
@@ -24,14 +24,8 @@ describe('/partner', () => {
]);
await Promise.all([
createPartner(
{ id: user2.userId },
{ headers: asBearerAuth(user1.accessToken) }
),
createPartner(
{ id: user1.userId },
{ headers: asBearerAuth(user2.accessToken) }
),
createPartner({ id: user2.userId }, { headers: asBearerAuth(user1.accessToken) }),
createPartner({ id: user1.userId }, { headers: asBearerAuth(user2.accessToken) }),
]);
});
@@ -66,9 +60,7 @@ 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(app).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -89,17 +81,13 @@ describe('/partner', () => {
.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(app).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -112,17 +100,13 @@ describe('/partner', () => {
.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(app).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -142,9 +126,7 @@ describe('/partner', () => {
.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' }));
});
});
});
+50 -17
View File
@@ -9,6 +9,7 @@ describe('/activity', () => {
let admin: LoginResponseDto;
let visiblePerson: PersonResponseDto;
let hiddenPerson: PersonResponseDto;
let multipleAssetsPerson: PersonResponseDto;
beforeAll(async () => {
apiUtils.setup();
@@ -19,7 +20,7 @@ describe('/activity', () => {
beforeEach(async () => {
await dbUtils.reset(['person']);
[visiblePerson, hiddenPerson] = await Promise.all([
[visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([
apiUtils.createPerson(admin.accessToken, {
name: 'visible_person',
}),
@@ -27,13 +28,20 @@ describe('/activity', () => {
name: 'hidden_person',
isHidden: true,
}),
apiUtils.createPerson(admin.accessToken, {
name: 'multiple_assets_person',
}),
]);
const asset = await apiUtils.createAsset(admin.accessToken);
const asset1 = await apiUtils.createAsset(admin.accessToken);
const asset2 = await apiUtils.createAsset(admin.accessToken);
await Promise.all([
dbUtils.createFace({ assetId: asset.id, personId: visiblePerson.id }),
dbUtils.createFace({ assetId: asset.id, personId: hiddenPerson.id }),
dbUtils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
dbUtils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
dbUtils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
]);
});
@@ -55,9 +63,10 @@ describe('/activity', () => {
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
total: 3,
hidden: 1,
people: [
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
],
@@ -65,24 +74,23 @@ describe('/activity', () => {
});
it('should return only visible people', async () => {
const { status, body } = await request(app)
.get('/person')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/person').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
total: 3,
hidden: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
people: [
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'visible_person' }),
],
});
});
});
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/person/${uuidDto.notFound}`
);
const { status, body } = await request(app).get(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -107,11 +115,36 @@ describe('/activity', () => {
});
});
describe('GET /person/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/person/${multipleAssetsPerson.id}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(app)
.get(`/person/${uuidDto.notFound}/statistics`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should return the correct number of assets', async () => {
const { status, body } = await request(app)
.get(`/person/${multipleAssetsPerson.id}/statistics`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ assets: 2 }));
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/person/${uuidDto.notFound}`
);
const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -139,7 +172,7 @@ describe('/activity', () => {
birthDate: '123567',
response: 'Not found or no person.write access',
},
{ birthDate: 123567, response: 'Not found or no person.write access' },
{ birthDate: 123_567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(app)
.put(`/person/${uuidDto.notFound}`)
+2 -6
View File
@@ -97,9 +97,7 @@ 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(app).get('/server-info/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -145,9 +143,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(app).get('/server-info/media-types');
expect(status).toBe(200);
expect(body).toEqual({
sidecar: ['.xmp'],
+44 -81
View File
@@ -46,14 +46,8 @@ describe('/shared-link', () => {
]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([
createAlbum(
{ createAlbumDto: { albumName: 'album' } },
{ headers: asBearerAuth(user1.accessToken) },
),
createAlbum(
{ createAlbumDto: { albumName: 'deleted album' } },
{ headers: asBearerAuth(user2.accessToken) },
),
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
@@ -65,47 +59,38 @@ describe('/shared-link', () => {
),
]);
[
linkWithDeletedAlbum,
linkWithAlbum,
linkWithAssets,
linkWithPassword,
linkWithMetadata,
linkWithoutMetadata,
] = await Promise.all([
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: deletedAlbum.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'foo',
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
await Promise.all([
apiUtils.createSharedLink(user2.accessToken, {
type: SharedLinkType.Album,
albumId: deletedAlbum.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset1.id],
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'foo',
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: true,
}),
apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await deleteUser(
{ id: user2.userId },
{ headers: asBearerAuth(admin.accessToken) },
);
await deleteUser({ id: user2.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /shared-link', () => {
@@ -146,17 +131,13 @@ describe('/shared-link', () => {
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(app)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).get('/shared-link/me').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403);
});
it('should get data for correct shared link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body).toEqual(
@@ -178,18 +159,14 @@ describe('/shared-link', () => {
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithDeletedAlbum.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidShareKey);
});
it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithPassword.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithPassword.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidSharePassword);
@@ -211,9 +188,7 @@ describe('/shared-link', () => {
});
it('should return metadata for album shared link', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithMetadata.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -229,9 +204,7 @@ describe('/shared-link', () => {
});
it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(app)
.get('/shared-link/me')
.query({ key: linkWithoutMetadata.key });
const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
@@ -247,9 +220,7 @@ describe('/shared-link', () => {
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/shared-link/${linkWithAlbum.id}`,
);
const { status, body } = await request(app).get(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -276,9 +247,7 @@ describe('/shared-link', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Shared link not found' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
});
});
@@ -308,9 +277,7 @@ describe('/shared-link', () => {
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid albumId' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
});
it('should require a valid asset id', async () => {
@@ -320,9 +287,7 @@ describe('/shared-link', () => {
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid assetIds' }),
);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
});
it('should create a shared link', async () => {
@@ -424,9 +389,7 @@ describe('/shared-link', () => {
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/shared-link/${linkWithAlbum.id}`,
);
const { status, body } = await request(app).delete(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
+2 -8
View File
@@ -18,9 +18,7 @@ describe('/system-config', () => {
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(app).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -32,11 +30,7 @@ describe('/system-config', () => {
.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(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});
+4 -14
View File
@@ -32,24 +32,16 @@ describe('/trash', () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
const before = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(before.length).toBeGreaterThanOrEqual(1);
const { status } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await wsUtils.waitForEvent({ event: 'delete', assetId });
const after = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(after.length).toBe(0);
});
});
@@ -69,9 +61,7 @@ describe('/trash', () => {
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
.post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
+11 -31
View File
@@ -22,10 +22,7 @@ describe('/server-info', () => {
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
await deleteUser(
{ id: deletedUser.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /user', () => {
@@ -36,9 +33,7 @@ describe('/server-info', () => {
});
it('should get users', async () => {
const { status, body } = await request(app)
.get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(4);
expect(body).toEqual(
@@ -47,7 +42,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
@@ -63,7 +58,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
@@ -81,7 +76,7 @@ describe('/server-info', () => {
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
])
]),
);
});
});
@@ -112,9 +107,7 @@ describe('/server-info', () => {
});
it('should get my info', async () => {
const { status, body } = await request(app)
.get(`/user/me`)
.set('Authorization', `Bearer ${admin.accessToken}`);
const { status, body } = await request(app).get(`/user/me`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
@@ -125,9 +118,7 @@ describe('/server-info', () => {
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/user`)
.send(createUserDto.user1);
const { status, body } = await request(app).post(`/user`).send(createUserDto.user1);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -181,9 +172,7 @@ describe('/server-info', () => {
describe('DELETE /user/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/user/${userToDelete.userId}`
);
const { status, body } = await request(app).delete(`/user/${userToDelete.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
@@ -241,10 +230,7 @@ describe('/server-info', () => {
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
@@ -261,10 +247,7 @@ describe('/server-info', () => {
});
it('should update first and last name', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
@@ -284,10 +267,7 @@ describe('/server-info', () => {
});
it('should update memories enabled', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/user`)
.send({
+20 -16
View File
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises';
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
beforeAll(() => {
@@ -24,27 +24,17 @@ describe(`immich login-key`, () => {
});
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'
);
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 () => {
it('should login and save auth.yml with 600', async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli([
'login-key',
app,
`${key.secret}`,
]);
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in...',
'Logging in to http://127.0.0.1:2283/api',
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
@@ -55,4 +45,18 @@ describe(`immich login-key`, () => {
const mode = (stats.mode & 0o777).toString(8);
expect(mode).toEqual('600');
});
it('should login without /api in the url', async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283',
'Discovered API at http://127.0.0.1:2283/api',
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
});
});
+10 -43
View File
@@ -1,13 +1,6 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
import {
apiUtils,
asKeyAuth,
cliUtils,
dbUtils,
immichCli,
testAssetDir,
} from 'src/utils';
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich upload`, () => {
@@ -25,16 +18,10 @@ describe(`immich upload`, () => {
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
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.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
);
expect(exitCode).toBe(0);
@@ -70,15 +57,9 @@ describe(`immich upload`, () => {
});
it('should add existing assets to albums', async () => {
const response1 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
@@ -89,17 +70,10 @@ describe(`immich upload`, () => {
const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums1.length).toBe(0);
const response2 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
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('All assets were already uploaded, nothing to do.'),
expect.stringContaining('Successfully updated 9 assets'),
]),
);
@@ -147,17 +121,10 @@ describe(`immich upload`, () => {
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}`,
);
await symlink(`${testAssetDir}/albums/nature/${file}`, `/tmp/albums/nature/${file}`);
}
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature`,
'--delete',
]);
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 });
+31
View File
@@ -0,0 +1,31 @@
import { PNG } from 'pngjs';
const createPNG = (r: number, g: number, b: number) => {
const image = new PNG({ width: 1, height: 1 });
image.data[0] = r;
image.data[1] = g;
image.data[2] = b;
image.data[3] = 255;
return PNG.sync.write(image);
};
function* newPngFactory() {
for (let r = 0; r < 255; r++) {
for (let g = 0; g < 255; g++) {
for (let b = 0; b < 255; b++) {
yield createPNG(r, g, b);
}
}
}
}
const pngFactory = newPngFactory();
export const makeRandomImage = () => {
const { value } = pngFactory.next();
if (!value) {
throw new Error('Ran out of random asset data');
}
return value;
};
+2 -4
View File
@@ -1,4 +1,4 @@
import { spawn, exec } from 'child_process';
import { exec, spawn } from 'node:child_process';
export default async () => {
let _resolve: () => unknown;
@@ -19,8 +19,6 @@ export default async () => {
await ready;
return async () => {
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve()),
);
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
};
};
+49 -82
View File
@@ -3,11 +3,14 @@ import {
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
CreateLibraryDto,
CreateUserDto,
PersonUpdateDto,
SharedLinkCreateDto,
ValidateLibraryDto,
createAlbum,
createApiKey,
createLibrary,
createPerson,
createSharedLink,
createUser,
@@ -18,18 +21,19 @@ import {
setAdminOnboarding,
signUpAdmin,
updatePerson,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createHash } from 'node:crypto';
import { access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:stream';
import { promisify } from 'node:util';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
const execPromise = promisify(exec);
@@ -43,6 +47,7 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
export const testAssetDirInternal = '/data/assets';
export const tempDir = tmpdir();
const serverContainerName = 'immich-e2e-server';
@@ -70,20 +75,16 @@ let client: pg.Client | null = null;
export const fileUtils = {
reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`,
);
await execPromise(`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
},
unzip: async (input: string, output: string) => {
await execPromise(`unzip -o -d "${output}" "${input}"`);
},
sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'),
};
export const dbUtils = {
createFace: async ({
assetId,
personId,
}: {
assetId: string;
personId: string;
}) => {
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
if (!client) {
return;
}
@@ -91,31 +92,28 @@ export const dbUtils = {
const vector = Array.from({ length: 512 }, Math.random);
const embedding = `[${vector.join(',')}]`;
await client.query(
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
[assetId, personId, embedding],
);
await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [
assetId,
personId,
embedding,
]);
},
setPersonThumbnail: async (personId: string) => {
if (!client) {
return;
}
await client.query(
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
[personId],
);
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
},
reset: async (tables?: string[]) => {
try {
if (!client) {
client = new pg.Client(
'postgres://postgres:postgres@127.0.0.1:5433/immich',
);
client = new pg.Client('postgres://postgres:postgres@127.0.0.1:5433/immich');
await client.connect();
}
tables = tables || [
'libraries',
'shared_links',
'person',
'albums',
@@ -157,7 +155,7 @@ export interface CliResponse {
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 _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args];
const child = spawn('node', _args, {
stdio: 'pipe',
});
@@ -223,12 +221,8 @@ export const wsUtils = {
return new Promise<Socket>((resolve) => {
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) =>
onEvent({ event: 'upload', assetId: data.id }),
)
.on('on_asset_delete', (assetId: string) =>
onEvent({ event: 'delete', assetId }),
)
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'upload', assetId: data.id }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'delete', assetId }))
.connect();
});
},
@@ -241,21 +235,14 @@ export const wsUtils = {
set.clear();
}
},
waitForEvent: async ({
event,
assetId,
timeout: ms,
}: WaitOptions): Promise<void> => {
waitForEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise<void> => {
const set = events[event];
if (set.has(assetId)) {
return;
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${event} event`)),
ms || 5000,
);
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 5000);
callbacks[assetId] = () => {
clearTimeout(timeout);
@@ -265,6 +252,8 @@ export const wsUtils = {
},
};
type AssetData = { bytes?: Buffer; filename: string };
export const apiUtils = {
setup: () => {
defaults.baseUrl = app;
@@ -281,86 +270,64 @@ export const apiUtils = {
return response;
},
userSetup: async (accessToken: string, dto: CreateUserDto) => {
await createUser(
{ createUserDto: dto },
{ headers: asBearerAuth(accessToken) },
);
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) },
);
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) },
),
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
createAsset: async (
accessToken: string,
dto?: Partial<Omit<CreateAssetDto, 'assetData'>>,
data?: {
bytes?: Buffer;
filename?: string;
},
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...(dto || {}),
...dto,
};
const _assetData = {
bytes: randomBytes(32),
filename: 'example.jpg',
...(data || {}),
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
const builder = request(app)
.post(`/asset/upload`)
.attach('assetData', _assetData.bytes, _assetData.filename)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
builder.field(key, String(value));
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetFileUploadResponseDto;
},
getAssetInfo: (accessToken: string, id: string) =>
getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets(
{ assetBulkDeleteDto: { ids } },
{ headers: asBearerAuth(accessToken) },
),
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
// TODO fix createPerson to accept a body
let person = await createPerson({ headers: asBearerAuth(accessToken) });
const person = await createPerson({ headers: asBearerAuth(accessToken) });
await dbUtils.setPersonThumbnail(person.id);
if (!dto) {
return person;
}
return updatePerson(
{ id: person.id, personUpdateDto: dto },
{ headers: asBearerAuth(accessToken) },
);
return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) });
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) },
),
createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }),
createLibrary: (accessToken: string, dto: CreateLibraryDto) =>
createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) =>
validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
};
export const cliUtils = {
@@ -380,7 +347,7 @@ export const webUtils = {
value: accessToken,
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -390,7 +357,7 @@ export const webUtils = {
value: 'password',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
@@ -400,7 +367,7 @@ export const webUtils = {
value: 'true',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
expires: 1_742_402_728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiUtils, dbUtils, webUtils } from 'src/utils';
test.describe('Registration', () => {
@@ -68,7 +68,7 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Login' }).click();
// change password
expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page).toHaveURL('/auth/change-password');
await page.getByLabel('New Password').fill('new-password');
await page.getByLabel('Confirm Password').fill('new-password');
+4 -5
View File
@@ -1,18 +1,17 @@
import {
AlbumResponseDto,
AssetResponseDto,
AssetFileUploadResponseDto,
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 asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
@@ -29,7 +28,7 @@ test.describe('Shared Links', () => {
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
@@ -53,7 +52,7 @@ test.describe('Shared Links', () => {
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();
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
});
test('enter password for a shared link', async ({ page }) => {
+1
View File
@@ -18,5 +18,6 @@
"rootDirs": ["src"],
"baseUrl": "./"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+9 -1
View File
@@ -1,9 +1,17 @@
import { defineConfig } from 'vitest/config';
// skip `docker compose up` if `make e2e` was already run
const globalSetup: string[] = [];
try {
await fetch('http://127.0.0.1:2283/api/server-info/ping');
} catch {
globalSetup.push('src/setup.ts');
}
export default defineConfig({
test: {
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
globalSetup: ['src/setup.ts'],
globalSetup,
poolOptions: {
threads: {
singleThread: true,
+2
View File
@@ -167,6 +167,8 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# VS Code
.vscode
*.onnx
*.zip
+1 -1
View File
@@ -39,7 +39,7 @@ FROM python:3.11-slim-bookworm@sha256:ce81dc539f0aedc9114cae640f8352fad83d37461c
FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino
USER root
FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04@sha256:85fb7ac694079fff1061a0140fd5b5a641997880e12112d92589c3bbb1e8b7ca as prod-cuda
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:8b51b1fe922964d73c482a267b5b519e990d90bf744ec7a40419923737caff6d as prod-cuda
COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
+8 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from socket import socket
from gunicorn.arbiter import Arbiter
from pydantic import BaseSettings
from pydantic import BaseModel, BaseSettings
from rich.console import Console
from rich.logging import RichHandler
from uvicorn import Server
@@ -15,6 +15,11 @@ from uvicorn.workers import UvicornWorker
from .schemas import ModelType
class PreloadModelData(BaseModel):
clip: str | None
facial_recognition: str | None
class Settings(BaseSettings):
cache_folder: str = "/cache"
model_ttl: int = 300
@@ -27,10 +32,12 @@ class Settings(BaseSettings):
model_inter_op_threads: int = 0
model_intra_op_threads: int = 0
ann: bool = True
preload: PreloadModelData | None = None
class Config:
env_prefix = "MACHINE_LEARNING_"
case_sensitive = False
env_nested_delimiter = "__"
class LogSettings(BaseSettings):
+13 -3
View File
@@ -17,7 +17,7 @@ from starlette.formparsers import MultiPartParser
from app.models.base import InferenceModel
from .config import log, settings
from .config import PreloadModelData, log, settings
from .models.cache import ModelCache
from .schemas import (
MessageResponse,
@@ -27,7 +27,7 @@ from .schemas import (
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
thread_pool: ThreadPoolExecutor | None = None
lock = threading.Lock()
active_requests = 0
@@ -51,6 +51,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
if settings.model_ttl > 0 and settings.model_ttl_poll_s > 0:
asyncio.ensure_future(idle_shutdown_task())
if settings.preload is not None:
await preload_models(settings.preload)
yield
finally:
log.handlers.clear()
@@ -61,6 +63,14 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
gc.collect()
async def preload_models(preload_models: PreloadModelData) -> None:
log.info(f"Preloading models: {preload_models}")
if preload_models.clip is not None:
await load(await model_cache.get(preload_models.clip, ModelType.CLIP))
if preload_models.facial_recognition is not None:
await load(await model_cache.get(preload_models.facial_recognition, ModelType.FACIAL_RECOGNITION))
def update_state() -> Iterator[None]:
global active_requests, last_called
active_requests += 1
@@ -103,7 +113,7 @@ async def predict(
except orjson.JSONDecodeError:
raise HTTPException(400, f"Invalid options JSON: {options}")
model = await load(await model_cache.get(model_name, model_type, **kwargs))
model = await load(await model_cache.get(model_name, model_type, ttl=settings.model_ttl, **kwargs))
model.configure(**kwargs)
outputs = await run(model.predict, inputs)
return ORJSONResponse(outputs)
+11 -26
View File
@@ -2,7 +2,7 @@ from typing import Any
from aiocache.backends.memory import SimpleMemoryCache
from aiocache.lock import OptimisticLock
from aiocache.plugins import BasePlugin, TimingPlugin
from aiocache.plugins import TimingPlugin
from app.models import from_model_type
@@ -15,28 +15,25 @@ class ModelCache:
def __init__(
self,
ttl: float | None = None,
revalidate: bool = False,
timeout: int | None = None,
profiling: bool = False,
) -> None:
"""
Args:
ttl: Unloads model after this duration. Disabled if None. Defaults to None.
revalidate: Resets TTL on cache hit. Useful to keep models in memory while active. Defaults to False.
timeout: Maximum allowed time for model to load. Disabled if None. Defaults to None.
profiling: Collects metrics for cache operations, adding slight overhead. Defaults to False.
"""
self.ttl = ttl
plugins = []
if revalidate:
plugins.append(RevalidationPlugin())
if profiling:
plugins.append(TimingPlugin())
self.cache = SimpleMemoryCache(ttl=ttl, timeout=timeout, plugins=plugins, namespace=None)
self.revalidate_enable = revalidate
self.cache = SimpleMemoryCache(timeout=timeout, plugins=plugins, namespace=None)
async def get(self, model_name: str, model_type: ModelType, **model_kwargs: Any) -> InferenceModel:
"""
@@ -49,11 +46,14 @@ class ModelCache:
"""
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
async with OptimisticLock(self.cache, key) as lock:
model: InferenceModel | None = await self.cache.get(key)
if model is None:
model = from_model_type(model_type, model_name, **model_kwargs)
await lock.cas(model, ttl=self.ttl)
await lock.cas(model, ttl=model_kwargs.get("ttl", None))
elif self.revalidate_enable:
await self.revalidate(key, model_kwargs.get("ttl", None))
return model
async def get_profiling(self) -> dict[str, float] | None:
@@ -62,21 +62,6 @@ class ModelCache:
return self.cache.profiling
class RevalidationPlugin(BasePlugin): # type: ignore[misc]
"""Revalidates cache item's TTL after cache hit."""
async def post_get(
self,
client: SimpleMemoryCache,
key: str,
ret: Any | None = None,
namespace: str | None = None,
**kwargs: Any,
) -> None:
if ret is None:
return
if namespace is not None:
key = client.build_key(key, namespace)
if key in client._handlers:
await client.expire(key, client.ttl)
async def revalidate(self, key: str, ttl: int | None) -> None:
if ttl is not None and key in self.cache._handlers:
await self.cache.expire(key, ttl)
+29 -9
View File
@@ -13,11 +13,12 @@ import onnxruntime as ort
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from pytest import MonkeyPatch
from pytest_mock import MockerFixture
from app.main import load
from app.main import load, preload_models
from .config import log, settings
from .config import Settings, log, settings
from .models.base import InferenceModel
from .models.cache import ModelCache
from .models.clip import MCLIPEncoder, OpenCLIPEncoder
@@ -509,20 +510,20 @@ class TestCache:
@mock.patch("app.models.cache.OptimisticLock", autospec=True)
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache(ttl=100)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
model_cache = ModelCache()
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
@mock.patch("app.models.cache.SimpleMemoryCache.expire")
async def test_revalidate_get(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache(ttl=100, revalidate=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
model_cache = ModelCache(revalidate=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
async def test_profiling(self, mock_get_model: mock.Mock) -> None:
model_cache = ModelCache(ttl=100, profiling=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
model_cache = ModelCache(profiling=True)
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, ttl=100)
profiling = await model_cache.get_profiling()
assert isinstance(profiling, dict)
assert profiling == model_cache.cache.profiling
@@ -548,6 +549,25 @@ class TestCache:
with pytest.raises(ValueError):
await model_cache.get("test_model_name", ModelType.CLIP, mode="text")
async def test_preloads_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__CLIP"] = "ViT-B-32__openai"
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"] = "buffalo_s"
settings = Settings()
assert settings.preload is not None
assert settings.preload.clip == "ViT-B-32__openai"
assert settings.preload.facial_recognition == "buffalo_s"
model_cache = ModelCache()
monkeypatch.setattr("app.main.model_cache", model_cache)
await preload_models(settings.preload)
assert len(model_cache.cache._cache) == 2
assert mock_get_model.call_count == 2
await model_cache.get("ViT-B-32__openai", ModelType.CLIP, ttl=100)
await model_cache.get("buffalo_s", ModelType.FACIAL_RECOGNITION, ttl=100)
assert mock_get_model.call_count == 2
@pytest.mark.asyncio
class TestLoad:
+18 -14
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
[[package]]
name = "aiocache"
@@ -2030,21 +2030,21 @@ sympy = "*"
[[package]]
name = "onnxruntime-gpu"
version = "1.17.0"
version = "1.17.1"
description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
optional = false
python-versions = "*"
files = [
{file = "onnxruntime_gpu-1.17.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1f2a4e0468ac0bd8246996c3d5dbba92cbbaca874bcd7f9cee4e99ce6eb27f5b"},
{file = "onnxruntime_gpu-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:0721b7930d7abed3730b2335e639e60d94ec411bb4d35a0347cc9c8b52c34540"},
{file = "onnxruntime_gpu-1.17.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:be0314afe399943904de7c1ca797cbcc63e6fad60eb85d3df6422f81dd94e79e"},
{file = "onnxruntime_gpu-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:52125c24b21406d1431e43de1c98cea29c21e0cceba80db530b7e4c9216d86ea"},
{file = "onnxruntime_gpu-1.17.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bb802d8033885c412269f8bc8877d8779b0dc874df6fb9df8b796cba7276ad66"},
{file = "onnxruntime_gpu-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:8c43533e3e5335eaa78059fb86b849a4faded513a00c1feaaa205ca5af51c40f"},
{file = "onnxruntime_gpu-1.17.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:1d461455bba160836d6c11c648c8fd4e4500d5c17096a13e6c2c9d22a4abd436"},
{file = "onnxruntime_gpu-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4398f2175a92f4b35d95279a6294a89c462f24de058a2736ee1d498bab5a16"},
{file = "onnxruntime_gpu-1.17.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1d0e3805cd1c024aba7f4ae576fd08545fc27530a2aaad2b3c8ac0ee889fbd05"},
{file = "onnxruntime_gpu-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc1da5b93363ee600b5b220b04eeec51ad2c2b3e96f0b7615b16b8a173c88001"},
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e34ecb2b527ee1265135ae74cd99ea198ff344b8221929a920596a1e461e2bbb"},
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:37786c0f225be90da0a66ca413fe125a925a0900263301cc4dbcad4ff0404673"},
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3bde190a683ec84ecf61bd390f3c275d388efe72404633df374c52c557ce6d4d"},
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:5206c84caa770efcc2ca819f71ec007a244ed748ca04e7ff76b86df1a096d2c8"},
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0396ec73de565a64509d96dff154f531f8da8023c191f771ceba47a3f4efc266"},
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:8531d4a833c8e978c5ff1de7b3bcc4126bbe58ea71fae54ddce58fe8777cb136"},
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7b831f9eafd626f3d44955420a4b1b84f9ffcb987712a0ab6a37d1ee9f2f7a45"},
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:a389334d3797519d4b12077db32b8764f1ce54374d0f89235edc04efe8bc192c"},
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:27aeaa36385e459b3867577ed7f68c1756de79aa68f57141d4ae2a31c84f6a33"},
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b46094ea348aff6c6494402ac4260e2d2aba0522ae13e1ae29d98a29384ed70"},
]
[package.dependencies]
@@ -2055,6 +2055,11 @@ packaging = "*"
protobuf = "*"
sympy = "*"
[package.source]
type = "legacy"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple"
reference = "cuda12"
[[package]]
name = "onnxruntime-openvino"
version = "1.15.0"
@@ -2628,7 +2633,6 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -3619,4 +3623,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.12"
content-hash = "c982d5c5fee76ca102d823010a538f287ac98583f330ebee3c0775c5f42f117d"
content-hash = "c947090d326e81179054b7ce4dded311df8b7ca5a56680d5e9459cf8ca18df1a"
+6 -1
View File
@@ -45,7 +45,7 @@ onnxruntime = "^1.15.0"
optional = true
[tool.poetry.group.cuda.dependencies]
onnxruntime-gpu = "^1.15.0"
onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"}
[tool.poetry.group.openvino]
optional = true
@@ -59,6 +59,11 @@ optional = true
[tool.poetry.group.armnn.dependencies]
onnxruntime = "^1.15.0"
[[tool.poetry.source]]
name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
priority = "explicit"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+5 -1
View File
@@ -62,8 +62,12 @@ 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"
make open-api
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
npm --prefix web version "$SERVER_PUMP"
npm --prefix web i --package-lock-only
npm --prefix cli i --package-lock-only
npm --prefix e2e i --package-lock-only
poetry --directory machine-learning version "$SERVER_PUMP"
fi
@@ -226,7 +226,7 @@ class ControlBottomAppBar extends ConsumerWidget {
if (selectionAssetState.hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "Upload",
label: "control_bottom_app_bar_upload".tr(),
onPressed: enabled
? () => showDialog(
context: context,
+2 -4
View File
@@ -1401,7 +1401,7 @@ void (empty response body)
[[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)
# **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
@@ -1432,7 +1432,6 @@ final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime |
final key = key_example; // String |
final duration = duration_example; // String |
final isArchived = true; // bool |
final isExternal = true; // bool |
final isFavorite = true; // bool |
final isOffline = true; // bool |
final isReadOnly = true; // bool |
@@ -1442,7 +1441,7 @@ final livePhotoData = BINARY_DATA_HERE; // MultipartFile |
final sidecarData = BINARY_DATA_HERE; // MultipartFile |
try {
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
print(result);
} catch (e) {
print('Exception when calling AssetApi->uploadFile: $e\n');
@@ -1461,7 +1460,6 @@ Name | Type | Description | Notes
**key** | **String**| | [optional]
**duration** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isExternal** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isOffline** | **bool**| | [optional]
**isReadOnly** | **bool**| | [optional]
+1
View File
@@ -13,6 +13,7 @@ Name | Type | Description | Notes
**name** | **String** | |
**password** | **String** | |
**quotaSizeInBytes** | **int** | | [optional]
**shouldChangePassword** | **bool** | | [optional]
**storageLabel** | **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)
+3
View File
@@ -8,9 +8,12 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**city** | **String** | |
**country** | **String** | |
**id** | **String** | |
**lat** | **double** | |
**lon** | **double** | |
**state** | **String** | |
[[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
View File
@@ -27,6 +27,7 @@ Name | Type | Description | Notes
**make** | **String** | | [optional]
**model** | **String** | | [optional]
**page** | **num** | | [optional]
**personIds** | **List<String>** | | [optional] [default to const []]
**query** | **String** | |
**size** | **num** | | [optional]
**state** | **String** | | [optional]
+2
View File
@@ -13,6 +13,7 @@ Name | Type | Description | Notes
**buttonText** | **String** | |
**clientId** | **String** | |
**clientSecret** | **String** | |
**defaultStorageQuota** | **num** | |
**enabled** | **bool** | |
**issuerUrl** | **String** | |
**mobileOverrideEnabled** | **bool** | |
@@ -20,6 +21,7 @@ Name | Type | Description | Notes
**scope** | **String** | |
**signingAlgorithm** | **String** | |
**storageLabelClaim** | **String** | |
**storageQuotaClaim** | **String** | |
[[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 -11
View File
@@ -1676,8 +1676,6 @@ class AssetApi {
///
/// * [bool] isArchived:
///
/// * [bool] isExternal:
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
@@ -1691,7 +1689,7 @@ class AssetApi {
/// * [MultipartFile] livePhotoData:
///
/// * [MultipartFile] sidecarData:
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/upload';
@@ -1739,10 +1737,6 @@ class AssetApi {
hasFields = true;
mp.fields[r'isArchived'] = parameterToString(isArchived);
}
if (isExternal != null) {
hasFields = true;
mp.fields[r'isExternal'] = parameterToString(isExternal);
}
if (isFavorite != null) {
hasFields = true;
mp.fields[r'isFavorite'] = parameterToString(isFavorite);
@@ -1806,8 +1800,6 @@ class AssetApi {
///
/// * [bool] isArchived:
///
/// * [bool] isExternal:
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
@@ -1821,8 +1813,8 @@ class AssetApi {
/// * [MultipartFile] livePhotoData:
///
/// * [MultipartFile] sidecarData:
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isFavorite: isFavorite, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+18 -1
View File
@@ -18,6 +18,7 @@ class CreateUserDto {
required this.name,
required this.password,
this.quotaSizeInBytes,
this.shouldChangePassword,
this.storageLabel,
});
@@ -37,6 +38,14 @@ class CreateUserDto {
int? quotaSizeInBytes;
///
/// 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.
///
bool? shouldChangePassword;
String? storageLabel;
@override
@@ -46,6 +55,7 @@ class CreateUserDto {
other.name == name &&
other.password == password &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.storageLabel == storageLabel;
@override
@@ -56,10 +66,11 @@ class CreateUserDto {
(name.hashCode) +
(password.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, storageLabel=$storageLabel]';
String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -76,6 +87,11 @@ class CreateUserDto {
} else {
// json[r'quotaSizeInBytes'] = null;
}
if (this.shouldChangePassword != null) {
json[r'shouldChangePassword'] = this.shouldChangePassword;
} else {
// json[r'shouldChangePassword'] = null;
}
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
@@ -97,6 +113,7 @@ class CreateUserDto {
name: mapValueOfType<String>(json, r'name')!,
password: mapValueOfType<String>(json, r'password')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
);
}
+39 -3
View File
@@ -13,38 +13,68 @@ part of openapi.api;
class MapMarkerResponseDto {
/// Returns a new [MapMarkerResponseDto] instance.
MapMarkerResponseDto({
required this.city,
required this.country,
required this.id,
required this.lat,
required this.lon,
required this.state,
});
String? city;
String? country;
String id;
double lat;
double lon;
String? state;
@override
bool operator ==(Object other) => identical(this, other) || other is MapMarkerResponseDto &&
other.city == city &&
other.country == country &&
other.id == id &&
other.lat == lat &&
other.lon == lon;
other.lon == lon &&
other.state == state;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(city == null ? 0 : city!.hashCode) +
(country == null ? 0 : country!.hashCode) +
(id.hashCode) +
(lat.hashCode) +
(lon.hashCode);
(lon.hashCode) +
(state == null ? 0 : state!.hashCode);
@override
String toString() => 'MapMarkerResponseDto[id=$id, lat=$lat, lon=$lon]';
String toString() => 'MapMarkerResponseDto[city=$city, country=$country, id=$id, lat=$lat, lon=$lon, state=$state]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.city != null) {
json[r'city'] = this.city;
} else {
// json[r'city'] = null;
}
if (this.country != null) {
json[r'country'] = this.country;
} else {
// json[r'country'] = null;
}
json[r'id'] = this.id;
json[r'lat'] = this.lat;
json[r'lon'] = this.lon;
if (this.state != null) {
json[r'state'] = this.state;
} else {
// json[r'state'] = null;
}
return json;
}
@@ -56,9 +86,12 @@ class MapMarkerResponseDto {
final json = value.cast<String, dynamic>();
return MapMarkerResponseDto(
city: mapValueOfType<String>(json, r'city'),
country: mapValueOfType<String>(json, r'country'),
id: mapValueOfType<String>(json, r'id')!,
lat: (mapValueOfType<num>(json, r'lat')!).toDouble(),
lon: (mapValueOfType<num>(json, r'lon')!).toDouble(),
state: mapValueOfType<String>(json, r'state'),
);
}
return null;
@@ -106,9 +139,12 @@ class MapMarkerResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'city',
'country',
'id',
'lat',
'lon',
'state',
};
}
+10 -1
View File
@@ -32,6 +32,7 @@ class SmartSearchDto {
this.make,
this.model,
this.page,
this.personIds = const [],
required this.query,
this.size,
this.state,
@@ -199,6 +200,8 @@ class SmartSearchDto {
///
num? page;
List<String> personIds;
String query;
///
@@ -312,6 +315,7 @@ class SmartSearchDto {
other.make == make &&
other.model == model &&
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.query == query &&
other.size == size &&
other.state == state &&
@@ -348,6 +352,7 @@ class SmartSearchDto {
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(query.hashCode) +
(size == null ? 0 : size!.hashCode) +
(state == null ? 0 : state!.hashCode) +
@@ -363,7 +368,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@override
String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isExternal=$isExternal, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isReadOnly=$isReadOnly, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isExternal=$isExternal, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isReadOnly=$isReadOnly, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -462,6 +467,7 @@ class SmartSearchDto {
} else {
// json[r'page'] = null;
}
json[r'personIds'] = this.personIds;
json[r'query'] = this.query;
if (this.size != null) {
json[r'size'] = this.size;
@@ -549,6 +555,9 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
query: mapValueOfType<String>(json, r'query')!,
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
+19 -3
View File
@@ -18,6 +18,7 @@ class SystemConfigOAuthDto {
required this.buttonText,
required this.clientId,
required this.clientSecret,
required this.defaultStorageQuota,
required this.enabled,
required this.issuerUrl,
required this.mobileOverrideEnabled,
@@ -25,6 +26,7 @@ class SystemConfigOAuthDto {
required this.scope,
required this.signingAlgorithm,
required this.storageLabelClaim,
required this.storageQuotaClaim,
});
bool autoLaunch;
@@ -37,6 +39,8 @@ class SystemConfigOAuthDto {
String clientSecret;
num defaultStorageQuota;
bool enabled;
String issuerUrl;
@@ -51,6 +55,8 @@ class SystemConfigOAuthDto {
String storageLabelClaim;
String storageQuotaClaim;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto &&
other.autoLaunch == autoLaunch &&
@@ -58,13 +64,15 @@ class SystemConfigOAuthDto {
other.buttonText == buttonText &&
other.clientId == clientId &&
other.clientSecret == clientSecret &&
other.defaultStorageQuota == defaultStorageQuota &&
other.enabled == enabled &&
other.issuerUrl == issuerUrl &&
other.mobileOverrideEnabled == mobileOverrideEnabled &&
other.mobileRedirectUri == mobileRedirectUri &&
other.scope == scope &&
other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim;
other.storageLabelClaim == storageLabelClaim &&
other.storageQuotaClaim == storageQuotaClaim;
@override
int get hashCode =>
@@ -74,16 +82,18 @@ class SystemConfigOAuthDto {
(buttonText.hashCode) +
(clientId.hashCode) +
(clientSecret.hashCode) +
(defaultStorageQuota.hashCode) +
(enabled.hashCode) +
(issuerUrl.hashCode) +
(mobileOverrideEnabled.hashCode) +
(mobileRedirectUri.hashCode) +
(scope.hashCode) +
(signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode);
(storageLabelClaim.hashCode) +
(storageQuotaClaim.hashCode);
@override
String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim]';
String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -92,6 +102,7 @@ class SystemConfigOAuthDto {
json[r'buttonText'] = this.buttonText;
json[r'clientId'] = this.clientId;
json[r'clientSecret'] = this.clientSecret;
json[r'defaultStorageQuota'] = this.defaultStorageQuota;
json[r'enabled'] = this.enabled;
json[r'issuerUrl'] = this.issuerUrl;
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
@@ -99,6 +110,7 @@ class SystemConfigOAuthDto {
json[r'scope'] = this.scope;
json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim;
json[r'storageQuotaClaim'] = this.storageQuotaClaim;
return json;
}
@@ -115,6 +127,7 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
@@ -122,6 +135,7 @@ class SystemConfigOAuthDto {
scope: mapValueOfType<String>(json, r'scope')!,
signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
storageQuotaClaim: mapValueOfType<String>(json, r'storageQuotaClaim')!,
);
}
return null;
@@ -174,6 +188,7 @@ class SystemConfigOAuthDto {
'buttonText',
'clientId',
'clientSecret',
'defaultStorageQuota',
'enabled',
'issuerUrl',
'mobileOverrideEnabled',
@@ -181,6 +196,7 @@ class SystemConfigOAuthDto {
'scope',
'signingAlgorithm',
'storageLabelClaim',
'storageQuotaClaim',
};
}
+1 -1
View File
@@ -135,7 +135,7 @@ void main() {
// TODO
});
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String duration, bool isArchived, bool isExternal, bool isFavorite, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String duration, bool isArchived, bool isFavorite, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
test('test uploadFile', () async {
// TODO
});
+5
View File
@@ -41,6 +41,11 @@ void main() {
// TODO
});
// bool shouldChangePassword
test('to test the property `shouldChangePassword`', () async {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
+15
View File
@@ -16,6 +16,16 @@ void main() {
// final instance = MapMarkerResponseDto();
group('test MapMarkerResponseDto', () {
// String city
test('to test the property `city`', () async {
// TODO
});
// String country
test('to test the property `country`', () async {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
@@ -31,6 +41,11 @@ void main() {
// TODO
});
// String state
test('to test the property `state`', () async {
// TODO
});
});
+5
View File
@@ -111,6 +111,11 @@ void main() {
// TODO
});
// List<String> personIds (default value: const [])
test('to test the property `personIds`', () async {
// TODO
});
// String query
test('to test the property `query`', () async {
// TODO
+10
View File
@@ -41,6 +41,11 @@ void main() {
// TODO
});
// num defaultStorageQuota
test('to test the property `defaultStorageQuota`', () async {
// TODO
});
// bool enabled
test('to test the property `enabled`', () async {
// TODO
@@ -76,6 +81,11 @@ void main() {
// TODO
});
// String storageQuotaClaim
test('to test the property `storageQuotaClaim`', () async {
// TODO
});
});
+1 -3
View File
@@ -17,9 +17,7 @@ function dart {
}
function typescript {
rm -rf ./typescript-sdk/client
npx --yes @openapitools/openapi-generator-cli generate -g typescript-axios -i ./immich-openapi-specs.json -o ./typescript-sdk/axios-client --additional-properties=useSingleRequestParameter=true,supportsES6=true
npx --yes oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/fetch-client.ts
npx --yes oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
npm --prefix typescript-sdk ci && npm --prefix typescript-sdk run build
}
+37 -8
View File
@@ -3396,7 +3396,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -3521,7 +3521,7 @@
}
],
"responses": {
"201": {
"204": {
"description": ""
}
},
@@ -3566,7 +3566,7 @@
"required": true
},
"responses": {
"201": {
"204": {
"description": ""
}
},
@@ -7541,9 +7541,6 @@
"isArchived": {
"type": "boolean"
},
"isExternal": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
@@ -7675,6 +7672,9 @@
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"nullable": true,
"type": "string"
@@ -8294,6 +8294,14 @@
},
"MapMarkerResponseDto": {
"properties": {
"city": {
"nullable": true,
"type": "string"
},
"country": {
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
@@ -8304,12 +8312,19 @@
"lon": {
"format": "double",
"type": "number"
},
"state": {
"nullable": true,
"type": "string"
}
},
"required": [
"city",
"country",
"id",
"lat",
"lon"
"lon",
"state"
],
"type": "object"
},
@@ -9542,6 +9557,12 @@
"page": {
"type": "number"
},
"personIds": {
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"type": "string"
},
@@ -9927,6 +9948,9 @@
"clientSecret": {
"type": "string"
},
"defaultStorageQuota": {
"type": "number"
},
"enabled": {
"type": "boolean"
},
@@ -9947,6 +9971,9 @@
},
"storageLabelClaim": {
"type": "string"
},
"storageQuotaClaim": {
"type": "string"
}
},
"required": [
@@ -9955,13 +9982,15 @@
"buttonText",
"clientId",
"clientSecret",
"defaultStorageQuota",
"enabled",
"issuerUrl",
"mobileOverrideEnabled",
"mobileRedirectUri",
"scope",
"signingAlgorithm",
"storageLabelClaim"
"storageLabelClaim",
"storageQuotaClaim"
],
"type": "object"
},
@@ -1,4 +0,0 @@
wwwroot/*.js
node_modules
typings
dist
@@ -1 +0,0 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm
@@ -1,23 +0,0 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md
@@ -1,8 +0,0 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts
@@ -1 +0,0 @@
7.2.0
File diff suppressed because it is too large Load Diff
@@ -1,86 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "/api".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: RawAxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}
@@ -1,150 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}
@@ -1,110 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}
@@ -1,57 +0,0 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'
@@ -1,18 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";
-4
View File
@@ -1,4 +0,0 @@
export * from './axios-client';
export * as base from './axios-client/base';
export * as configuration from './axios-client/configuration';
export * as common from './axios-client/common';
+5 -121
View File
@@ -1,25 +1,17 @@
{
"name": "@immich/sdk",
"version": "1.92.1",
"version": "1.97.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.92.1",
"version": "1.97.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@oazapfts/runtime": "^1.0.0",
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"axios": "^1.6.7"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/@oazapfts/runtime": {
@@ -29,122 +21,14 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"version": "20.11.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"optional": true,
"peer": true
},
"node_modules/axios": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
"optional": true,
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"optional": true,
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"optional": true,
"peer": true,
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"optional": true,
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"optional": true,
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"optional": true,
"peer": true
},
"node_modules/typescript": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
+6 -18
View File
@@ -1,35 +1,23 @@
{
"name": "@immich/sdk",
"version": "1.92.1",
"version": "1.97.0",
"description": "",
"type": "module",
"main": "./build/fetch/index.js",
"types": "./build/fetch/index.d.ts",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
"./axios": {
"types": "./build/axios/axios.d.ts",
"default": "./build/axios/axios.js"
},
".": {
"types": "./build/fetch/fetch.d.ts",
"default": "./build/fetch/fetch.js"
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "tsc -b ./tsconfig.axios.json ./tsconfig.fetch.json"
"build": "tsc"
},
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@oazapfts/runtime": "^1.0.0",
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"axios": "^1.6.7"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
}
@@ -260,9 +260,12 @@ export type AssetJobsDto = {
name: AssetJobName;
};
export type MapMarkerResponseDto = {
city: string | null;
country: string | null;
id: string;
lat: number;
lon: number;
state: string | null;
};
export type MemoryLaneResponseDto = {
assets: AssetResponseDto[];
@@ -289,7 +292,6 @@ export type CreateAssetDto = {
fileCreatedAt: string;
fileModifiedAt: string;
isArchived?: boolean;
isExternal?: boolean;
isFavorite?: boolean;
isOffline?: boolean;
isReadOnly?: boolean;
@@ -672,6 +674,7 @@ export type SmartSearchDto = {
make?: string;
model?: string;
page?: number;
personIds?: string[];
query: string;
size?: number;
state?: string;
@@ -877,6 +880,7 @@ export type SystemConfigOAuthDto = {
buttonText: string;
clientId: string;
clientSecret: string;
defaultStorageQuota: number;
enabled: boolean;
issuerUrl: string;
mobileOverrideEnabled: boolean;
@@ -884,6 +888,7 @@ export type SystemConfigOAuthDto = {
scope: string;
signingAlgorithm: string;
storageLabelClaim: string;
storageQuotaClaim: string;
};
export type SystemConfigPasswordLoginDto = {
enabled: boolean;
@@ -953,6 +958,7 @@ export type CreateUserDto = {
name: string;
password: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type UpdateUserDto = {
@@ -1,13 +0,0 @@
{
"include": ["axios.ts", "axios-client/**/*"],
"compilerOptions": {
"target": "esnext",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "build/axios",
"module": "esnext",
"moduleResolution": "Bundler",
"lib": ["esnext"]
}
}
@@ -1,13 +1,13 @@
{
"include": ["fetch.ts", "fetch-client.ts"],
"compilerOptions": {
"target": "esnext",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "build/fetch",
"outDir": "build",
"module": "esnext",
"moduleResolution": "Bundler",
"lib": ["esnext", "dom"]
}
},
"include": ["src/**/*.ts"]
}
+6
View File
@@ -25,6 +25,12 @@ module.exports = {
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
// Note: you must disable the base rule as it can report incorrect errors
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
curly: 2,
'prettier/prettier': 0,
},
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env sh
/usr/src/app/start.sh immich-admin "$1"
/usr/src/app/start.sh immich-admin "$@"
-475
View File
@@ -1,475 +0,0 @@
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';
describe(`${LibraryController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let user: LoginResponseDto;
beforeAll(async () => {
const app = await testApp.create();
server = app.getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await restoreTempFolder();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
user = await api.authApi.login(server, userDto.user1);
});
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/library');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with a default upload library', async () => {
const { status, body } = await request(server)
.get('/library')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]),
);
});
});
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/library').send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require admin authentication', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an external library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
}),
);
});
it('should not create an external library with duplicate import paths', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
importPaths: ['/path', '/path'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(["All importPaths's elements must be unique"]));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
});
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should create an upload library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns'));
});
});
describe('PUT /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/library/${uuidStub.notFound}`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
describe('external library', () => {
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 change the library name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
name: 'New Library Name',
}),
);
});
it('should not set an empty name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [IMMICH_TEST_ASSET_TEMP_PATH] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: [IMMICH_TEST_ASSET_TEMP_PATH],
}),
);
});
it('should reject an empty import path', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty']));
});
it('should reject duplicate import paths', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(["All importPaths's elements must be unique"]));
});
it('should change the exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
exclusionPatterns: ['**/Raw/**'],
}),
);
});
it('should reject duplicate exclusion patterns', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(["All exclusionPatterns's elements must be unique"]));
});
it('should reject an empty exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
});
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require admin access', async () => {
const { status, body } = await request(server)
.get(`/library/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should get library by id', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
});
describe('DELETE /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/library/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should not delete the last upload library', async () => {
const [defaultLibrary] = await api.libraryApi.getAll(server, admin.accessToken);
expect(defaultLibrary).toBeDefined();
const { status, body } = await request(server)
.delete(`/library/${defaultLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noDeleteUploadLibrary);
});
it('should delete an external library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({});
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/removeOffline`).send({});
expect(status).toBe(401);
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 pass with no import paths', async () => {
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
});
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)`,
});
});
});
});
});
+1 -1
View File
@@ -4,8 +4,8 @@ import { InfraModule, InfraTestModule, dataSource } from '@app/infra';
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { randomBytes } from 'crypto';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../../src/microservices/app.service';
import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test';
+2 -2
View File
@@ -36,14 +36,14 @@ export const libraryApi = {
.post(`/library/${id}/scan`)
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
expect(status).toBe(204);
},
removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
const { status } = await request(server)
.post(`/library/${id}/removeOffline`)
.set('Authorization', `Bearer ${accessToken}`)
.send();
expect(status).toBe(201);
expect(status).toBe(204);
},
getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
const { body, status } = await request(server)
+1
View File
@@ -12,6 +12,7 @@ services:
command: ['/usr/src/app/bin/immich-test', 'jobs']
volumes:
- /usr/src/app/node_modules
- ../test/assets:/usr/src/app/test/assets:ro
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
@@ -208,7 +208,7 @@ describe(`Library watcher (e2e)`, () => {
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });
});
it('should use an updated import paths', async () => {
it('should use an updated import path', async () => {
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`, { recursive: true });
await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [
+4 -5
View File
@@ -45,12 +45,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets.length).toBeGreaterThan(2);
const { status, body } = await request(server)
const { status } = await request(server)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({});
expect(status).toBe(204);
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).toHaveLength(1);
@@ -392,7 +391,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
.post(`/library/${library.id}/removeOffline`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(201);
expect(status).toBe(204);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
@@ -416,7 +415,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
.post(`/library/${library.id}/removeOffline`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send();
expect(status).toBe(201);
expect(status).toBe(204);
const assetsAfter = await api.assetApi.getAllAssets(server, admin.accessToken);
+618 -335
View File
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