mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 19:02:30 -08:00
Compare commits
1 Commits
feat/graph
...
feat/ignor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d66c7a36 |
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
uses: docker/build-push-action@v6.12.0
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -174,7 +174,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
uses: docker/build-push-action@v6.12.0
|
||||
with:
|
||||
context: ${{ env.context }}
|
||||
file: ${{ env.file }}
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6.13.0
|
||||
uses: docker/build-push-action@v6.12.0
|
||||
with:
|
||||
context: ${{ env.context }}
|
||||
file: ${{ env.file }}
|
||||
|
||||
16
cli/package-lock.json
generated
16
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.50",
|
||||
"version": "2.2.48",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.50",
|
||||
"version": "2.2.48",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
@@ -52,14 +52,14 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -1482,9 +1482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
||||
"version": "22.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
|
||||
"integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.50",
|
||||
"version": "2.2.48",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
|
||||
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.0"
|
||||
constraints = "4.52.0"
|
||||
version = "4.50.0"
|
||||
constraints = "4.50.0"
|
||||
hashes = [
|
||||
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
|
||||
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
|
||||
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
|
||||
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
|
||||
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
|
||||
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
|
||||
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
|
||||
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
|
||||
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
|
||||
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
|
||||
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
|
||||
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
|
||||
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
|
||||
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
|
||||
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
|
||||
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
|
||||
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
|
||||
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
|
||||
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
|
||||
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
|
||||
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
|
||||
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
|
||||
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
|
||||
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
|
||||
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
|
||||
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
|
||||
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
|
||||
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
|
||||
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
|
||||
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
|
||||
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
|
||||
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
|
||||
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
|
||||
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
|
||||
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
|
||||
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
|
||||
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
|
||||
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
|
||||
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
|
||||
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
|
||||
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
|
||||
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
|
||||
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
|
||||
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
|
||||
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
|
||||
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
|
||||
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
|
||||
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
|
||||
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
|
||||
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
|
||||
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
|
||||
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
|
||||
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
|
||||
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
|
||||
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.0"
|
||||
version = "4.50.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.0"
|
||||
constraints = "4.52.0"
|
||||
version = "4.50.0"
|
||||
constraints = "4.50.0"
|
||||
hashes = [
|
||||
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
|
||||
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
|
||||
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
|
||||
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
|
||||
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
|
||||
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
|
||||
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
|
||||
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
|
||||
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
|
||||
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
|
||||
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
|
||||
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
|
||||
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
|
||||
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
|
||||
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
|
||||
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
|
||||
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
|
||||
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
|
||||
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
|
||||
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
|
||||
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
|
||||
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
|
||||
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
|
||||
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
|
||||
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
|
||||
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
|
||||
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
|
||||
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
|
||||
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
|
||||
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
|
||||
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
|
||||
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
|
||||
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
|
||||
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
|
||||
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
|
||||
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
|
||||
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
|
||||
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
|
||||
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
|
||||
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
|
||||
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
|
||||
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
|
||||
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
|
||||
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
|
||||
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
|
||||
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
|
||||
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
|
||||
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
|
||||
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
|
||||
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
|
||||
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
|
||||
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
|
||||
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
|
||||
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
|
||||
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.0"
|
||||
version = "4.50.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5
|
||||
image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
|
||||
### Mobile app
|
||||
|
||||
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x and FVM to be installed on your system.
|
||||
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x to be installed on your system.
|
||||
|
||||
Please refer to the [Flutter's official documentation](https://flutter.dev/docs/get-started/install) for more information on setting up the toolchain on your machine.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
- ARM NN (Mali)
|
||||
- CUDA (NVIDIA GPUs with [compute capability](https://developer.nvidia.com/cuda-gpus) 5.2 or higher)
|
||||
- OpenVINO (Intel GPUs such as Iris Xe and Arc)
|
||||
- OpenVINO (Intel discrete GPUs such as Iris Xe and Arc)
|
||||
|
||||
## Limitations
|
||||
|
||||
@@ -43,9 +43,8 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
#### OpenVINO
|
||||
|
||||
- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM.
|
||||
- The server must have a discrete GPU, i.e. Iris Xe or Arc. Expect issues when attempting to use integrated graphics.
|
||||
- Ensure the server's kernel version is new enough to use the device for hardware accceleration.
|
||||
- Expect higher RAM usage when using OpenVINO compared to CPU processing.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -44,12 +44,12 @@ export default function VersionSwitcher(): JSX.Element {
|
||||
return (
|
||||
versions.length > 0 && (
|
||||
<DropdownNavbarItem
|
||||
className="version-switcher-34ab39"
|
||||
className="navbar__item"
|
||||
label={label}
|
||||
mobile={windowSize === 'mobile'}
|
||||
items={versions.map(({ label, url }) => ({
|
||||
label,
|
||||
to: url + location.pathname + location.hash,
|
||||
to: url + location.pathname,
|
||||
target: '_self',
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -75,11 +75,6 @@ div[class^='announcementBar_'] {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* workaround for version switcher PR 15894 */
|
||||
div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function HomepageHeader() {
|
||||
|
||||
<Link
|
||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
|
||||
to="https://immich.store"
|
||||
to="https://demo.immich.app/"
|
||||
>
|
||||
Buy Merch
|
||||
</Link>
|
||||
|
||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,12 +1,4 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.126.1",
|
||||
"url": "https://v1.126.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.126.0",
|
||||
"url": "https://v1.126.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.125.7",
|
||||
"url": "https://v1.125.7.archive.immich.app"
|
||||
|
||||
@@ -32,6 +32,9 @@ services:
|
||||
- database
|
||||
ports:
|
||||
- 2285:2285
|
||||
cap_drop:
|
||||
# We need this to perform testing on permission errors
|
||||
- DAC_OVERRIDE
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:905c4ee67b8e0aa955331960d2aa745781e6bd89afc44a8584bfd13bc890f0ae
|
||||
|
||||
20
e2e/package-lock.json
generated
20
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-e2e",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
@@ -15,7 +15,7 @@
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@types/oidc-provider": "^8.5.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.50",
|
||||
"version": "2.2.48",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
@@ -92,14 +92,14 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -1666,9 +1666,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
||||
"version": "22.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
|
||||
"integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@types/oidc-provider": "^8.5.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
|
||||
import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, existsSync, promises, rmSync, unlinkSync } from 'node:fs';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { userDto, uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
@@ -492,6 +492,34 @@ describe('/libraries', () => {
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
|
||||
});
|
||||
|
||||
it('should handle permission errors on import paths without error', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/`],
|
||||
});
|
||||
|
||||
const stat = await promises.stat(`${testAssetDir}/temp/directoryA`);
|
||||
const mode = stat.mode;
|
||||
|
||||
chmodSync(`${testAssetDir}/temp/directoryB`, 0o000);
|
||||
|
||||
const { status } = await request(app)
|
||||
.post(`/libraries/${library.id}/scan`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
expect(status).toBe(204);
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||
|
||||
chmodSync(`${testAssetDir}/temp/directoryB`, mode);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(1);
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined();
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should reimport a modified file', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
|
||||
@@ -195,7 +195,6 @@ describe('/people', () => {
|
||||
.send({
|
||||
name: 'New Person',
|
||||
birthDate: '1990-01-01',
|
||||
color: '#333',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
@@ -274,24 +273,6 @@ describe('/people', () => {
|
||||
expect(body).toMatchObject({ birthDate: null });
|
||||
});
|
||||
|
||||
it('should set a color', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${visiblePerson.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ color: '#555' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ color: '#555' });
|
||||
});
|
||||
|
||||
it('should clear a color', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${visiblePerson.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ color: null });
|
||||
expect(status).toBe(200);
|
||||
expect(body.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should mark a person as favorite', async () => {
|
||||
const person = await utils.createPerson(admin.accessToken, {
|
||||
name: 'visible_person',
|
||||
|
||||
@@ -150,30 +150,6 @@ describe('/shared-links', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter on albumId', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/shared-links?albumId=${album.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: linkWithAlbum.id }),
|
||||
expect.objectContaining({ id: linkWithPassword.id }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find 0 albums', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/shared-links?albumId=${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not get shared links created by other users', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/shared-links')
|
||||
|
||||
@@ -436,7 +436,6 @@
|
||||
"back_close_deselect": "Back, close, or deselect",
|
||||
"backward": "Backward",
|
||||
"birthdate_saved": "Date of birth saved successfully",
|
||||
"show_shared_links": "Show shared links",
|
||||
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
||||
"blurred_background": "Blurred background",
|
||||
"bugs_and_feature_requests": "Bugs & Feature Requests",
|
||||
@@ -769,10 +768,8 @@
|
||||
"go_to_search": "Go to search",
|
||||
"go_to_folder": "Go to folder",
|
||||
"group_albums_by": "Group albums by...",
|
||||
"group_country": "Group by country",
|
||||
"group_no": "No grouping",
|
||||
"group_owner": "Group by owner",
|
||||
"group_places_by": "Group places by...",
|
||||
"group_year": "Group by year",
|
||||
"has_quota": "Has quota",
|
||||
"hi_user": "Hi {name} ({email})",
|
||||
@@ -805,7 +802,6 @@
|
||||
"include_shared_albums": "Include shared albums",
|
||||
"include_shared_partner_assets": "Include shared partner assets",
|
||||
"individual_share": "Individual share",
|
||||
"individual_shares": "Individual shares",
|
||||
"info": "Info",
|
||||
"interval": {
|
||||
"day_at_onepm": "Every day at 1pm",
|
||||
@@ -991,7 +987,6 @@
|
||||
"pick_a_location": "Pick a location",
|
||||
"place": "Place",
|
||||
"places": "Places",
|
||||
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
|
||||
"play": "Play",
|
||||
"play_memories": "Play memories",
|
||||
"play_motion_photo": "Play Motion Photo",
|
||||
@@ -1174,7 +1169,6 @@
|
||||
"shared_from_partner": "Photos from {partner}",
|
||||
"shared_link_options": "Shared link options",
|
||||
"shared_links": "Shared links",
|
||||
"shared_links_description": "Share photos and videos with a link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||
"shared_with_partner": "Shared with {partner}",
|
||||
"sharing": "Sharing",
|
||||
@@ -1284,7 +1278,6 @@
|
||||
"unfavorite": "Unfavorite",
|
||||
"unhide_person": "Unhide person",
|
||||
"unknown": "Unknown",
|
||||
"unknown_country": "Unknown Country",
|
||||
"unknown_year": "Unknown Year",
|
||||
"unlimited": "Unlimited",
|
||||
"unlink_motion_video": "Unlink motion video",
|
||||
|
||||
874
i18n/fa.json
874
i18n/fa.json
@@ -312,157 +312,157 @@
|
||||
"admin_password": "رمز عبور مدیر",
|
||||
"administration": "مدیریت",
|
||||
"advanced": "پیشرفته",
|
||||
"album_added": "آلبوم اضافه شد",
|
||||
"album_added": "",
|
||||
"album_added_notification_setting_description": "",
|
||||
"album_cover_updated": "جلد آلبوم بهروزرسانی شد",
|
||||
"album_info_updated": "اطلاعات آلبوم بهروزرسانی شد",
|
||||
"album_name": "نام آلبوم",
|
||||
"album_options": "گزینههای آلبوم",
|
||||
"album_updated": "آلبوم بهروزرسانی شد",
|
||||
"album_cover_updated": "",
|
||||
"album_info_updated": "",
|
||||
"album_name": "",
|
||||
"album_options": "",
|
||||
"album_updated": "",
|
||||
"album_updated_setting_description": "",
|
||||
"albums": "آلبومها",
|
||||
"albums": "",
|
||||
"albums_count": "",
|
||||
"all": "همه",
|
||||
"all_people": "همه افراد",
|
||||
"allow_dark_mode": "اجازه دادن به حالت تاریک",
|
||||
"allow_edits": "اجازه ویرایش",
|
||||
"api_key": "کلید API",
|
||||
"api_keys": "کلیدهای API",
|
||||
"app_settings": "تنظیمات برنامه",
|
||||
"appears_in": "ظاهر میشود در",
|
||||
"archive": "بایگانی",
|
||||
"all": "",
|
||||
"all_people": "",
|
||||
"allow_dark_mode": "",
|
||||
"allow_edits": "",
|
||||
"api_key": "",
|
||||
"api_keys": "",
|
||||
"app_settings": "",
|
||||
"appears_in": "",
|
||||
"archive": "",
|
||||
"archive_or_unarchive_photo": "",
|
||||
"archive_size": "اندازه بایگانی",
|
||||
"archive_size": "",
|
||||
"archive_size_description": "",
|
||||
"asset_offline": "محتوا آفلاین",
|
||||
"assets": "محتواها",
|
||||
"authorized_devices": "دستگاههای مجاز",
|
||||
"back": "بازگشت",
|
||||
"backward": "عقب",
|
||||
"blurred_background": "پسزمینه محو",
|
||||
"asset_offline": "",
|
||||
"assets": "",
|
||||
"authorized_devices": "",
|
||||
"back": "",
|
||||
"backward": "",
|
||||
"blurred_background": "",
|
||||
"bulk_delete_duplicates_confirmation": "",
|
||||
"bulk_keep_duplicates_confirmation": "",
|
||||
"bulk_trash_duplicates_confirmation": "",
|
||||
"camera": "دوربین",
|
||||
"camera_brand": "برند دوربین",
|
||||
"camera_model": "مدل دوربین",
|
||||
"cancel": "لغو",
|
||||
"cancel_search": "لغو جستجو",
|
||||
"cannot_merge_people": "نمیتوان افراد را ادغام کرد",
|
||||
"cannot_update_the_description": "نمیتوان توضیحات را بهروزرسانی کرد",
|
||||
"change_date": "تغییر تاریخ",
|
||||
"change_expiration_time": "تغییر زمان انقضا",
|
||||
"change_location": "تغییر مکان",
|
||||
"change_name": "تغییر نام",
|
||||
"change_name_successfully": "نام با موفقیت تغییر یافت",
|
||||
"change_password": "تغییر رمز عبور",
|
||||
"change_your_password": "رمز عبور خود را تغییر دهید",
|
||||
"camera": "",
|
||||
"camera_brand": "",
|
||||
"camera_model": "",
|
||||
"cancel": "",
|
||||
"cancel_search": "",
|
||||
"cannot_merge_people": "",
|
||||
"cannot_update_the_description": "",
|
||||
"change_date": "",
|
||||
"change_expiration_time": "",
|
||||
"change_location": "",
|
||||
"change_name": "",
|
||||
"change_name_successfully": "",
|
||||
"change_password": "",
|
||||
"change_your_password": "",
|
||||
"changed_visibility_successfully": "",
|
||||
"check_all": "انتخاب همه",
|
||||
"check_logs": "بررسی لاگها",
|
||||
"check_all": "",
|
||||
"check_logs": "",
|
||||
"choose_matching_people_to_merge": "",
|
||||
"city": "شهر",
|
||||
"clear": "پاک کردن",
|
||||
"clear_all": "پاک کردن همه",
|
||||
"clear_message": "پاک کردن پیام",
|
||||
"clear_value": "پاک کردن مقدار",
|
||||
"close": "بستن",
|
||||
"collapse_all": "جمع کردن همه",
|
||||
"color_theme": "تم رنگ",
|
||||
"comment_options": "گزینههای نظر",
|
||||
"comments_are_disabled": "نظرات غیرفعال هستند",
|
||||
"confirm": "تأیید",
|
||||
"confirm_admin_password": "تأیید رمز عبور مدیر",
|
||||
"city": "",
|
||||
"clear": "",
|
||||
"clear_all": "",
|
||||
"clear_message": "",
|
||||
"clear_value": "",
|
||||
"close": "",
|
||||
"collapse_all": "",
|
||||
"color_theme": "",
|
||||
"comment_options": "",
|
||||
"comments_are_disabled": "",
|
||||
"confirm": "",
|
||||
"confirm_admin_password": "",
|
||||
"confirm_delete_shared_link": "",
|
||||
"confirm_password": "تأیید رمز عبور",
|
||||
"contain": "شامل",
|
||||
"context": "زمینه",
|
||||
"continue": "ادامه",
|
||||
"copied_image_to_clipboard": "تصویر به کلیپبورد کپی شد.",
|
||||
"copied_to_clipboard": "به کلیپبورد کپی شد!",
|
||||
"copy_error": "خطا در کپی",
|
||||
"copy_file_path": "کپی مسیر فایل",
|
||||
"copy_image": "کپی تصویر",
|
||||
"copy_link": "کپی لینک",
|
||||
"copy_link_to_clipboard": "کپی لینک به کلیپبورد",
|
||||
"copy_password": "کپی رمز عبور",
|
||||
"copy_to_clipboard": "کپی به کلیپبورد",
|
||||
"country": "کشور",
|
||||
"cover": "جلد",
|
||||
"covers": "جلدها",
|
||||
"create": "ایجاد",
|
||||
"create_album": "ایجاد آلبوم",
|
||||
"create_library": "ایجاد کتابخانه",
|
||||
"create_link": "ایجاد لینک",
|
||||
"create_link_to_share": "ایجاد لینک برای اشتراکگذاری",
|
||||
"create_new_person": "ایجاد فرد جدید",
|
||||
"create_new_user": "ایجاد کاربر جدید",
|
||||
"create_user": "ایجاد کاربر",
|
||||
"created": "ایجاد شد",
|
||||
"current_device": "دستگاه فعلی",
|
||||
"confirm_password": "",
|
||||
"contain": "",
|
||||
"context": "",
|
||||
"continue": "",
|
||||
"copied_image_to_clipboard": "",
|
||||
"copied_to_clipboard": "",
|
||||
"copy_error": "",
|
||||
"copy_file_path": "",
|
||||
"copy_image": "",
|
||||
"copy_link": "",
|
||||
"copy_link_to_clipboard": "",
|
||||
"copy_password": "",
|
||||
"copy_to_clipboard": "",
|
||||
"country": "",
|
||||
"cover": "",
|
||||
"covers": "",
|
||||
"create": "",
|
||||
"create_album": "",
|
||||
"create_library": "",
|
||||
"create_link": "",
|
||||
"create_link_to_share": "",
|
||||
"create_new_person": "",
|
||||
"create_new_user": "",
|
||||
"create_user": "",
|
||||
"created": "",
|
||||
"current_device": "",
|
||||
"custom_locale": "",
|
||||
"custom_locale_description": "",
|
||||
"dark": "تاریک",
|
||||
"date_after": "تاریخ پس از",
|
||||
"date_and_time": "تاریخ و زمان",
|
||||
"date_before": "تاریخ قبل از",
|
||||
"date_range": "بازه زمانی",
|
||||
"day": "روز",
|
||||
"deduplicate_all": "حذف تکراریها به صورت کامل",
|
||||
"dark": "",
|
||||
"date_after": "",
|
||||
"date_and_time": "",
|
||||
"date_before": "",
|
||||
"date_range": "",
|
||||
"day": "",
|
||||
"deduplicate_all": "",
|
||||
"default_locale": "",
|
||||
"default_locale_description": "",
|
||||
"delete": "حذف",
|
||||
"delete_album": "حذف آلبوم",
|
||||
"delete": "",
|
||||
"delete_album": "",
|
||||
"delete_api_key_prompt": "",
|
||||
"delete_duplicates_confirmation": "",
|
||||
"delete_key": "حذف کلید",
|
||||
"delete_library": "حذف کتابخانه",
|
||||
"delete_link": "حذف لینک",
|
||||
"delete_shared_link": "حذف لینک اشتراکی",
|
||||
"delete_user": "حذف کاربر",
|
||||
"deleted_shared_link": "لینک اشتراکی حذف شد",
|
||||
"description": "توضیحات",
|
||||
"details": "جزئیات",
|
||||
"direction": "جهت",
|
||||
"disabled": "غیرفعال",
|
||||
"disallow_edits": "عدم اجازه ویرایش",
|
||||
"discover": "کشف کردن",
|
||||
"dismiss_all_errors": "رد تمام خطاها",
|
||||
"dismiss_error": "رد خطا",
|
||||
"display_options": "گزینههای نمایش",
|
||||
"display_order": "ترتیب نمایش",
|
||||
"display_original_photos": "نمایش عکسهای اصلی",
|
||||
"delete_key": "",
|
||||
"delete_library": "",
|
||||
"delete_link": "",
|
||||
"delete_shared_link": "",
|
||||
"delete_user": "",
|
||||
"deleted_shared_link": "",
|
||||
"description": "",
|
||||
"details": "",
|
||||
"direction": "",
|
||||
"disabled": "",
|
||||
"disallow_edits": "",
|
||||
"discover": "",
|
||||
"dismiss_all_errors": "",
|
||||
"dismiss_error": "",
|
||||
"display_options": "",
|
||||
"display_order": "",
|
||||
"display_original_photos": "",
|
||||
"display_original_photos_setting_description": "",
|
||||
"done": "انجام شد",
|
||||
"download": "دانلود",
|
||||
"download_settings": "تنظیمات دانلود",
|
||||
"download_settings_description": "مدیریت تنظیمات مرتبط با دانلود محتوا",
|
||||
"downloading": "در حال دانلود",
|
||||
"duplicates": "تکراریها",
|
||||
"done": "",
|
||||
"download": "",
|
||||
"download_settings": "",
|
||||
"download_settings_description": "",
|
||||
"downloading": "",
|
||||
"duplicates": "",
|
||||
"duplicates_description": "",
|
||||
"duration": "مدت زمان",
|
||||
"edit_album": "ویرایش آلبوم",
|
||||
"edit_avatar": "ویرایش آواتار",
|
||||
"edit_date": "ویرایش تاریخ",
|
||||
"edit_date_and_time": "ویرایش تاریخ و زمان",
|
||||
"edit_exclusion_pattern": "ویرایش الگوی استثناء",
|
||||
"edit_faces": "ویرایش چهرهها",
|
||||
"duration": "",
|
||||
"edit_album": "",
|
||||
"edit_avatar": "",
|
||||
"edit_date": "",
|
||||
"edit_date_and_time": "",
|
||||
"edit_exclusion_pattern": "",
|
||||
"edit_faces": "",
|
||||
"edit_import_path": "",
|
||||
"edit_import_paths": "",
|
||||
"edit_key": "ویرایش کلید",
|
||||
"edit_link": "ویرایش لینک",
|
||||
"edit_location": "ویرایش مکان",
|
||||
"edit_name": "ویرایش نام",
|
||||
"edit_people": "ویرایش افراد",
|
||||
"edit_title": "ویرایش عنوان",
|
||||
"edit_user": "ویرایش کاربر",
|
||||
"edited": "ویرایش شد",
|
||||
"editor": "ویرایشگر",
|
||||
"email": "ایمیل",
|
||||
"empty_trash": "خالی کردن سطل زباله",
|
||||
"end_date": "تاریخ پایان",
|
||||
"error": "خطا",
|
||||
"error_loading_image": "خطا در بارگذاری تصویر",
|
||||
"edit_key": "",
|
||||
"edit_link": "",
|
||||
"edit_location": "",
|
||||
"edit_name": "",
|
||||
"edit_people": "",
|
||||
"edit_title": "",
|
||||
"edit_user": "",
|
||||
"edited": "",
|
||||
"editor": "",
|
||||
"email": "",
|
||||
"empty_trash": "",
|
||||
"end_date": "",
|
||||
"error": "",
|
||||
"error_loading_image": "",
|
||||
"errors": {
|
||||
"exclusion_pattern_already_exists": "",
|
||||
"import_path_already_exists": "",
|
||||
@@ -530,400 +530,400 @@
|
||||
"unable_to_update_timeline_display_status": "",
|
||||
"unable_to_update_user": ""
|
||||
},
|
||||
"exit_slideshow": "خروج از نمایش اسلاید",
|
||||
"expand_all": "باز کردن همه",
|
||||
"expire_after": "منقضی شدن بعد از",
|
||||
"expired": "منقضی شده",
|
||||
"explore": "کاوش کردن",
|
||||
"export": "صادر کردن",
|
||||
"export_as_json": "صادر کردن بهصورت JSON",
|
||||
"extension": "پسوند",
|
||||
"external": "خارجی",
|
||||
"external_libraries": "کتابخانههای خارجی",
|
||||
"favorite": "علاقهمندی",
|
||||
"exit_slideshow": "",
|
||||
"expand_all": "",
|
||||
"expire_after": "",
|
||||
"expired": "",
|
||||
"explore": "",
|
||||
"export": "",
|
||||
"export_as_json": "",
|
||||
"extension": "",
|
||||
"external": "",
|
||||
"external_libraries": "",
|
||||
"favorite": "",
|
||||
"favorite_or_unfavorite_photo": "",
|
||||
"favorites": "علاقهمندیها",
|
||||
"favorites": "",
|
||||
"feature_photo_updated": "",
|
||||
"file_name": "نام فایل",
|
||||
"file_name_or_extension": "نام فایل یا پسوند",
|
||||
"filename": "نام فایل",
|
||||
"filetype": "نوع فایل",
|
||||
"filter_people": "فیلتر افراد",
|
||||
"file_name": "",
|
||||
"file_name_or_extension": "",
|
||||
"filename": "",
|
||||
"filetype": "",
|
||||
"filter_people": "",
|
||||
"find_them_fast": "",
|
||||
"fix_incorrect_match": "رفع تطابق نادرست",
|
||||
"forward": "جلو",
|
||||
"general": "عمومی",
|
||||
"get_help": "دریافت کمک",
|
||||
"getting_started": "شروع به کار",
|
||||
"go_back": "بازگشت",
|
||||
"go_to_search": "رفتن به جستجو",
|
||||
"group_albums_by": "گروهبندی آلبومها براساس...",
|
||||
"has_quota": "دارای سهمیه",
|
||||
"hide_gallery": "پنهان کردن گالری",
|
||||
"hide_password": "پنهان کردن رمز عبور",
|
||||
"hide_person": "پنهان کردن فرد",
|
||||
"host": "میزبان",
|
||||
"hour": "ساعت",
|
||||
"image": "تصویر",
|
||||
"immich_logo": "لوگوی Immich",
|
||||
"immich_web_interface": "رابط وب Immich",
|
||||
"import_from_json": "وارد کردن از JSON",
|
||||
"import_path": "مسیر وارد کردن",
|
||||
"fix_incorrect_match": "",
|
||||
"forward": "",
|
||||
"general": "",
|
||||
"get_help": "",
|
||||
"getting_started": "",
|
||||
"go_back": "",
|
||||
"go_to_search": "",
|
||||
"group_albums_by": "",
|
||||
"has_quota": "",
|
||||
"hide_gallery": "",
|
||||
"hide_password": "",
|
||||
"hide_person": "",
|
||||
"host": "",
|
||||
"hour": "",
|
||||
"image": "",
|
||||
"immich_logo": "",
|
||||
"immich_web_interface": "",
|
||||
"import_from_json": "",
|
||||
"import_path": "",
|
||||
"in_albums": "",
|
||||
"in_archive": "در بایگانی",
|
||||
"include_archived": "شامل بایگانی شدهها",
|
||||
"include_shared_albums": "شامل آلبومهای اشتراکی",
|
||||
"in_archive": "",
|
||||
"include_archived": "",
|
||||
"include_shared_albums": "",
|
||||
"include_shared_partner_assets": "",
|
||||
"individual_share": "اشتراک فردی",
|
||||
"info": "اطلاعات",
|
||||
"individual_share": "",
|
||||
"info": "",
|
||||
"interval": {
|
||||
"day_at_onepm": "",
|
||||
"hours": "",
|
||||
"night_at_midnight": "",
|
||||
"night_at_twoam": ""
|
||||
},
|
||||
"invite_people": "دعوت افراد",
|
||||
"invite_to_album": "دعوت به آلبوم",
|
||||
"jobs": "وظایف",
|
||||
"keep": "نگه داشتن",
|
||||
"keep_all": "نگه داشتن همه",
|
||||
"keyboard_shortcuts": "میانبرهای صفحهکلید",
|
||||
"language": "زبان",
|
||||
"language_setting_description": "انتخاب زبان دلخواه شما",
|
||||
"last_seen": "آخرین مشاهده",
|
||||
"leave": "ترک کردن",
|
||||
"let_others_respond": "اجازه به دیگران برای پاسخگویی",
|
||||
"level": "سطح",
|
||||
"library": "کتابخانه",
|
||||
"library_options": "گزینههای کتابخانه",
|
||||
"light": "روشن",
|
||||
"link_options": "گزینههای لینک",
|
||||
"link_to_oauth": "اتصال به OAuth",
|
||||
"linked_oauth_account": "حساب OAuth متصل شده",
|
||||
"list": "لیست",
|
||||
"loading": "در حال بارگذاری",
|
||||
"loading_search_results_failed": "بارگذاری نتایج جستجو ناموفق بود",
|
||||
"log_out": "خروج از سیستم",
|
||||
"log_out_all_devices": "خروج از همه دستگاهها",
|
||||
"login_has_been_disabled": "ورود غیرفعال شده است.",
|
||||
"look": "نگاه کردن",
|
||||
"loop_videos": "پخش مداوم ویدئوها",
|
||||
"invite_people": "",
|
||||
"invite_to_album": "",
|
||||
"jobs": "",
|
||||
"keep": "",
|
||||
"keep_all": "",
|
||||
"keyboard_shortcuts": "",
|
||||
"language": "",
|
||||
"language_setting_description": "",
|
||||
"last_seen": "",
|
||||
"leave": "",
|
||||
"let_others_respond": "",
|
||||
"level": "",
|
||||
"library": "",
|
||||
"library_options": "",
|
||||
"light": "",
|
||||
"link_options": "",
|
||||
"link_to_oauth": "",
|
||||
"linked_oauth_account": "",
|
||||
"list": "",
|
||||
"loading": "",
|
||||
"loading_search_results_failed": "",
|
||||
"log_out": "",
|
||||
"log_out_all_devices": "",
|
||||
"login_has_been_disabled": "",
|
||||
"look": "",
|
||||
"loop_videos": "",
|
||||
"loop_videos_description": "",
|
||||
"make": "ساختن",
|
||||
"manage_shared_links": "مدیریت لینکهای اشتراکی",
|
||||
"make": "",
|
||||
"manage_shared_links": "",
|
||||
"manage_sharing_with_partners": "",
|
||||
"manage_the_app_settings": "مدیریت تنظیمات برنامه",
|
||||
"manage_your_account": "مدیریت حساب کاربری شما",
|
||||
"manage_your_api_keys": "مدیریت کلیدهای API شما",
|
||||
"manage_your_devices": "مدیریت دستگاههای متصل",
|
||||
"manage_your_oauth_connection": "مدیریت اتصال OAuth شما",
|
||||
"map": "نقشه",
|
||||
"manage_the_app_settings": "",
|
||||
"manage_your_account": "",
|
||||
"manage_your_api_keys": "",
|
||||
"manage_your_devices": "",
|
||||
"manage_your_oauth_connection": "",
|
||||
"map": "",
|
||||
"map_marker_with_image": "",
|
||||
"map_settings": "تنظیمات نقشه",
|
||||
"matches": "تطابقها",
|
||||
"media_type": "نوع رسانه",
|
||||
"memories": "خاطرات",
|
||||
"map_settings": "",
|
||||
"matches": "",
|
||||
"media_type": "",
|
||||
"memories": "",
|
||||
"memories_setting_description": "",
|
||||
"memory": "خاطره",
|
||||
"menu": "منو",
|
||||
"merge": "ادغام",
|
||||
"merge_people": "ادغام افراد",
|
||||
"memory": "",
|
||||
"menu": "",
|
||||
"merge": "",
|
||||
"merge_people": "",
|
||||
"merge_people_limit": "",
|
||||
"merge_people_prompt": "",
|
||||
"merge_people_successfully": "ادغام افراد با موفقیت انجام شد",
|
||||
"minimize": "کوچک کردن",
|
||||
"minute": "دقیقه",
|
||||
"missing": "گمشده",
|
||||
"model": "مدل",
|
||||
"month": "ماه",
|
||||
"more": "بیشتر",
|
||||
"moved_to_trash": "به سطل زباله منتقل شد",
|
||||
"my_albums": "آلبومهای من",
|
||||
"name": "نام",
|
||||
"name_or_nickname": "نام یا لقب",
|
||||
"never": "هرگز",
|
||||
"new_api_key": "کلید API جدید",
|
||||
"new_password": "رمز عبور جدید",
|
||||
"new_person": "فرد جدید",
|
||||
"new_user_created": "کاربر جدید ایجاد شد",
|
||||
"newest_first": "جدیدترین ابتدا",
|
||||
"next": "بعدی",
|
||||
"next_memory": "خاطره بعدی",
|
||||
"no": "خیر",
|
||||
"merge_people_successfully": "",
|
||||
"minimize": "",
|
||||
"minute": "",
|
||||
"missing": "",
|
||||
"model": "",
|
||||
"month": "",
|
||||
"more": "",
|
||||
"moved_to_trash": "",
|
||||
"my_albums": "",
|
||||
"name": "",
|
||||
"name_or_nickname": "",
|
||||
"never": "",
|
||||
"new_api_key": "",
|
||||
"new_password": "",
|
||||
"new_person": "",
|
||||
"new_user_created": "",
|
||||
"newest_first": "",
|
||||
"next": "",
|
||||
"next_memory": "",
|
||||
"no": "",
|
||||
"no_albums_message": "",
|
||||
"no_archived_assets_message": "",
|
||||
"no_assets_message": "",
|
||||
"no_duplicates_found": "هیچ تکراری یافت نشد.",
|
||||
"no_exif_info_available": "اطلاعات EXIF موجود نیست",
|
||||
"no_duplicates_found": "",
|
||||
"no_exif_info_available": "",
|
||||
"no_explore_results_message": "",
|
||||
"no_favorites_message": "",
|
||||
"no_libraries_message": "",
|
||||
"no_name": "بدون نام",
|
||||
"no_places": "مکانی یافت نشد",
|
||||
"no_results": "نتیجهای یافت نشد",
|
||||
"no_name": "",
|
||||
"no_places": "",
|
||||
"no_results": "",
|
||||
"no_shared_albums_message": "",
|
||||
"not_in_any_album": "در هیچ آلبومی نیست",
|
||||
"not_in_any_album": "",
|
||||
"note_apply_storage_label_to_previously_uploaded assets": "",
|
||||
"note_unlimited_quota": "",
|
||||
"notes": "یادداشتها",
|
||||
"notification_toggle_setting_description": "اعلانهای ایمیلی را فعال کنید",
|
||||
"notifications": "اعلانها",
|
||||
"notifications_setting_description": "مدیریت اعلانها",
|
||||
"oauth": "OAuth",
|
||||
"offline": "آفلاین",
|
||||
"offline_paths": "مسیرهای آفلاین",
|
||||
"notes": "",
|
||||
"notification_toggle_setting_description": "",
|
||||
"notifications": "",
|
||||
"notifications_setting_description": "",
|
||||
"oauth": "",
|
||||
"offline": "",
|
||||
"offline_paths": "",
|
||||
"offline_paths_description": "",
|
||||
"ok": "تأیید",
|
||||
"oldest_first": "قدیمیترین ابتدا",
|
||||
"online": "آنلاین",
|
||||
"only_favorites": "فقط علاقهمندیها",
|
||||
"open_the_search_filters": "باز کردن فیلترهای جستجو",
|
||||
"options": "گزینهها",
|
||||
"organize_your_library": "کتابخانه خود را سازماندهی کنید",
|
||||
"other": "دیگر",
|
||||
"other_devices": "دستگاههای دیگر",
|
||||
"other_variables": "متغیرهای دیگر",
|
||||
"owned": "مالکیت",
|
||||
"owner": "مالک",
|
||||
"partner": "شریک",
|
||||
"partner_can_access": "{partner} میتواند دسترسی داشته باشد",
|
||||
"ok": "",
|
||||
"oldest_first": "",
|
||||
"online": "",
|
||||
"only_favorites": "",
|
||||
"open_the_search_filters": "",
|
||||
"options": "",
|
||||
"organize_your_library": "",
|
||||
"other": "",
|
||||
"other_devices": "",
|
||||
"other_variables": "",
|
||||
"owned": "",
|
||||
"owner": "",
|
||||
"partner": "",
|
||||
"partner_can_access": "",
|
||||
"partner_can_access_assets": "",
|
||||
"partner_can_access_location": "مکانهایی که عکسهای شما گرفته شدهاند",
|
||||
"partner_sharing": "اشتراکگذاری با شریک",
|
||||
"partners": "شرکا",
|
||||
"password": "رمز عبور",
|
||||
"password_does_not_match": "رمز عبور مطابقت ندارد",
|
||||
"password_required": "رمز عبور مورد نیاز است",
|
||||
"password_reset_success": "بازنشانی رمز عبور موفقیتآمیز بود",
|
||||
"partner_can_access_location": "",
|
||||
"partner_sharing": "",
|
||||
"partners": "",
|
||||
"password": "",
|
||||
"password_does_not_match": "",
|
||||
"password_required": "",
|
||||
"password_reset_success": "",
|
||||
"past_durations": {
|
||||
"days": "",
|
||||
"hours": "",
|
||||
"years": ""
|
||||
},
|
||||
"path": "مسیر",
|
||||
"pattern": "الگو",
|
||||
"pause": "توقف",
|
||||
"pause_memories": "توقف خاطرات",
|
||||
"paused": "متوقف شده",
|
||||
"pending": "در انتظار",
|
||||
"people": "افراد",
|
||||
"path": "",
|
||||
"pattern": "",
|
||||
"pause": "",
|
||||
"pause_memories": "",
|
||||
"paused": "",
|
||||
"pending": "",
|
||||
"people": "",
|
||||
"people_sidebar_description": "",
|
||||
"permanent_deletion_warning": "هشدار حذف دائمی",
|
||||
"permanent_deletion_warning_setting_description": "نمایش هشدار هنگام حذف دائمی محتواها",
|
||||
"permanently_delete": "حذف دائمی",
|
||||
"permanently_deleted_asset": "محتوای حذف شده دائمی",
|
||||
"person": "فرد",
|
||||
"photos": "عکسها",
|
||||
"permanent_deletion_warning": "",
|
||||
"permanent_deletion_warning_setting_description": "",
|
||||
"permanently_delete": "",
|
||||
"permanently_deleted_asset": "",
|
||||
"person": "",
|
||||
"photos": "",
|
||||
"photos_count": "",
|
||||
"photos_from_previous_years": "عکسهای سالهای گذشته",
|
||||
"pick_a_location": "یک مکان انتخاب کنید",
|
||||
"place": "مکان",
|
||||
"places": "مکانها",
|
||||
"play": "پخش",
|
||||
"play_memories": "پخش خاطرات",
|
||||
"play_motion_photo": "پخش عکس متحرک",
|
||||
"play_or_pause_video": "پخش یا توقف ویدیو",
|
||||
"port": "پورت",
|
||||
"preset": "پیشفرض",
|
||||
"preview": "پیشنمایش",
|
||||
"previous": "قبلی",
|
||||
"previous_memory": "خاطره قبلی",
|
||||
"previous_or_next_photo": "عکس قبلی یا بعدی",
|
||||
"primary": "اصلی",
|
||||
"profile_picture_set": "تصویر پروفایل تنظیم شد.",
|
||||
"public_share": "اشتراک عمومی",
|
||||
"reaction_options": "گزینههای واکنش",
|
||||
"read_changelog": "مطالعه تغییرات نسخه",
|
||||
"recent": "اخیر",
|
||||
"recent_searches": "جستجوهای اخیر",
|
||||
"refresh": "تازه سازی",
|
||||
"refreshed": "تازه سازی شد",
|
||||
"photos_from_previous_years": "",
|
||||
"pick_a_location": "",
|
||||
"place": "",
|
||||
"places": "",
|
||||
"play": "",
|
||||
"play_memories": "",
|
||||
"play_motion_photo": "",
|
||||
"play_or_pause_video": "",
|
||||
"port": "",
|
||||
"preset": "",
|
||||
"preview": "",
|
||||
"previous": "",
|
||||
"previous_memory": "",
|
||||
"previous_or_next_photo": "",
|
||||
"primary": "",
|
||||
"profile_picture_set": "",
|
||||
"public_share": "",
|
||||
"reaction_options": "",
|
||||
"read_changelog": "",
|
||||
"recent": "",
|
||||
"recent_searches": "",
|
||||
"refresh": "",
|
||||
"refreshed": "",
|
||||
"refreshes_every_file": "",
|
||||
"remove": "حذف",
|
||||
"remove_deleted_assets": "حذف محتواهای حذفشده",
|
||||
"remove_from_album": "حذف از آلبوم",
|
||||
"remove_from_favorites": "حذف از علاقهمندیها",
|
||||
"remove": "",
|
||||
"remove_deleted_assets": "",
|
||||
"remove_from_album": "",
|
||||
"remove_from_favorites": "",
|
||||
"remove_from_shared_link": "",
|
||||
"removed_api_key": "",
|
||||
"rename": "تغییر نام",
|
||||
"repair": "تعمیر",
|
||||
"rename": "",
|
||||
"repair": "",
|
||||
"repair_no_results_message": "",
|
||||
"replace_with_upload": "جایگزینی با آپلود",
|
||||
"replace_with_upload": "",
|
||||
"require_password": "",
|
||||
"require_user_to_change_password_on_first_login": "",
|
||||
"reset": "بازنشانی",
|
||||
"reset_password": "بازنشانی رمز عبور",
|
||||
"reset": "",
|
||||
"reset_password": "",
|
||||
"reset_people_visibility": "",
|
||||
"resolved_all_duplicates": "",
|
||||
"restore": "بازیابی",
|
||||
"restore_all": "بازیابی همه",
|
||||
"restore_user": "بازیابی کاربر",
|
||||
"resume": "ادامه",
|
||||
"restore": "",
|
||||
"restore_all": "",
|
||||
"restore_user": "",
|
||||
"resume": "",
|
||||
"retry_upload": "",
|
||||
"review_duplicates": "بررسی تکراریها",
|
||||
"role": "نقش",
|
||||
"save": "ذخیره",
|
||||
"review_duplicates": "",
|
||||
"role": "",
|
||||
"save": "",
|
||||
"saved_api_key": "",
|
||||
"saved_profile": "پروفایل ذخیره شد",
|
||||
"saved_settings": "تنظیمات ذخیره شد",
|
||||
"say_something": "چیزی بگویید",
|
||||
"scan_all_libraries": "اسکن همه کتابخانهها",
|
||||
"scan_settings": "تنظیمات اسکن",
|
||||
"saved_profile": "",
|
||||
"saved_settings": "",
|
||||
"say_something": "",
|
||||
"scan_all_libraries": "",
|
||||
"scan_settings": "",
|
||||
"scanning_for_album": "",
|
||||
"search": "جستجو",
|
||||
"search_albums": "جستجوی آلبومها",
|
||||
"search_by_context": "جستجو براساس زمینه",
|
||||
"search_camera_make": "جستجوی برند دوربین...",
|
||||
"search_camera_model": "جستجوی مدل دوربین...",
|
||||
"search_city": "جستجوی شهر...",
|
||||
"search_country": "جستجوی کشور...",
|
||||
"search_for_existing_person": "جستجوی فرد موجود",
|
||||
"search_people": "جستجوی افراد",
|
||||
"search_places": "جستجوی مکانها",
|
||||
"search_state": "جستجوی ایالت...",
|
||||
"search_timezone": "جستجوی منطقه زمانی...",
|
||||
"search_type": "نوع جستجو",
|
||||
"search": "",
|
||||
"search_albums": "",
|
||||
"search_by_context": "",
|
||||
"search_camera_make": "",
|
||||
"search_camera_model": "",
|
||||
"search_city": "",
|
||||
"search_country": "",
|
||||
"search_for_existing_person": "",
|
||||
"search_people": "",
|
||||
"search_places": "",
|
||||
"search_state": "",
|
||||
"search_timezone": "",
|
||||
"search_type": "",
|
||||
"search_your_photos": "",
|
||||
"searching_locales": "",
|
||||
"second": "ثانیه",
|
||||
"select_album_cover": "انتخاب جلد آلبوم",
|
||||
"select_all": "انتخاب همه",
|
||||
"select_avatar_color": "انتخاب رنگ آواتار",
|
||||
"select_face": "انتخاب چهره",
|
||||
"select_featured_photo": "انتخاب عکس ویژه",
|
||||
"select_keep_all": "انتخاب نگهداری همه",
|
||||
"select_library_owner": "انتخاب مالک کتابخانه",
|
||||
"select_new_face": "انتخاب چهره جدید",
|
||||
"select_photos": "انتخاب عکسها",
|
||||
"second": "",
|
||||
"select_album_cover": "",
|
||||
"select_all": "",
|
||||
"select_avatar_color": "",
|
||||
"select_face": "",
|
||||
"select_featured_photo": "",
|
||||
"select_keep_all": "",
|
||||
"select_library_owner": "",
|
||||
"select_new_face": "",
|
||||
"select_photos": "",
|
||||
"select_trash_all": "",
|
||||
"selected": "انتخاب شده",
|
||||
"send_message": "ارسال پیام",
|
||||
"send_welcome_email": "ارسال ایمیل خوشآمدگویی",
|
||||
"server_stats": "آمار سرور",
|
||||
"set": "تنظیم",
|
||||
"selected": "",
|
||||
"send_message": "",
|
||||
"send_welcome_email": "",
|
||||
"server_stats": "",
|
||||
"set": "",
|
||||
"set_as_album_cover": "",
|
||||
"set_as_profile_picture": "",
|
||||
"set_date_of_birth": "تنظیم تاریخ تولد",
|
||||
"set_profile_picture": "تنظیم تصویر پروفایل",
|
||||
"set_date_of_birth": "",
|
||||
"set_profile_picture": "",
|
||||
"set_slideshow_to_fullscreen": "",
|
||||
"settings": "تنظیمات",
|
||||
"settings_saved": "تنظیمات ذخیره شد",
|
||||
"share": "اشتراکگذاری",
|
||||
"shared": "مشترک",
|
||||
"shared_by": "مشترک توسط",
|
||||
"settings": "",
|
||||
"settings_saved": "",
|
||||
"share": "",
|
||||
"shared": "",
|
||||
"shared_by": "",
|
||||
"shared_by_you": "",
|
||||
"shared_from_partner": "عکسها از {partner}",
|
||||
"shared_links": "لینکهای اشتراکی",
|
||||
"shared_from_partner": "",
|
||||
"shared_links": "",
|
||||
"shared_photos_and_videos_count": "",
|
||||
"shared_with_partner": "مشترک با {partner}",
|
||||
"sharing": "اشتراکگذاری",
|
||||
"shared_with_partner": "",
|
||||
"sharing": "",
|
||||
"sharing_sidebar_description": "",
|
||||
"show_album_options": "نمایش گزینههای آلبوم",
|
||||
"show_album_options": "",
|
||||
"show_and_hide_people": "",
|
||||
"show_file_location": "نمایش مسیر فایل",
|
||||
"show_gallery": "نمایش گالری",
|
||||
"show_hidden_people": "نمایش افراد پنهان",
|
||||
"show_file_location": "",
|
||||
"show_gallery": "",
|
||||
"show_hidden_people": "",
|
||||
"show_in_timeline": "",
|
||||
"show_in_timeline_setting_description": "",
|
||||
"show_keyboard_shortcuts": "",
|
||||
"show_metadata": "نمایش اطلاعات متا",
|
||||
"show_metadata": "",
|
||||
"show_or_hide_info": "",
|
||||
"show_password": "نمایش رمز عبور",
|
||||
"show_password": "",
|
||||
"show_person_options": "",
|
||||
"show_progress_bar": "نمایش نوار پیشرفت",
|
||||
"show_search_options": "نمایش گزینههای جستجو",
|
||||
"shuffle": "تصادفی",
|
||||
"sign_out": "خروج",
|
||||
"sign_up": "ثبتنام",
|
||||
"size": "اندازه",
|
||||
"skip_to_content": "رفتن به محتوا",
|
||||
"slideshow": "نمایش اسلاید",
|
||||
"slideshow_settings": "تنظیمات نمایش اسلاید",
|
||||
"show_progress_bar": "",
|
||||
"show_search_options": "",
|
||||
"shuffle": "",
|
||||
"sign_out": "",
|
||||
"sign_up": "",
|
||||
"size": "",
|
||||
"skip_to_content": "",
|
||||
"slideshow": "",
|
||||
"slideshow_settings": "",
|
||||
"sort_albums_by": "",
|
||||
"stack": "پشته",
|
||||
"stack": "",
|
||||
"stack_selected_photos": "",
|
||||
"stacktrace": "",
|
||||
"start": "شروع",
|
||||
"start_date": "تاریخ شروع",
|
||||
"state": "ایالت",
|
||||
"status": "وضعیت",
|
||||
"stop_motion_photo": "توقف عکس متحرک",
|
||||
"start": "",
|
||||
"start_date": "",
|
||||
"state": "",
|
||||
"status": "",
|
||||
"stop_motion_photo": "",
|
||||
"stop_photo_sharing": "",
|
||||
"stop_photo_sharing_description": "",
|
||||
"stop_sharing_photos_with_user": "",
|
||||
"storage": "فضای ذخیرهسازی",
|
||||
"storage_label": "برچسب فضای ذخیرهسازی",
|
||||
"storage": "",
|
||||
"storage_label": "",
|
||||
"storage_usage": "",
|
||||
"submit": "ارسال",
|
||||
"suggestions": "پیشنهادات",
|
||||
"submit": "",
|
||||
"suggestions": "",
|
||||
"sunrise_on_the_beach": "",
|
||||
"swap_merge_direction": "تغییر جهت ادغام",
|
||||
"sync": "همگامسازی",
|
||||
"template": "الگو",
|
||||
"theme": "تم",
|
||||
"theme_selection": "انتخاب تم",
|
||||
"swap_merge_direction": "",
|
||||
"sync": "",
|
||||
"template": "",
|
||||
"theme": "",
|
||||
"theme_selection": "",
|
||||
"theme_selection_description": "",
|
||||
"time_based_memories": "",
|
||||
"timezone": "منطقه زمانی",
|
||||
"to_archive": "بایگانی",
|
||||
"to_favorite": "به علاقهمندیها",
|
||||
"timezone": "",
|
||||
"to_archive": "",
|
||||
"to_favorite": "",
|
||||
"to_trash": "",
|
||||
"toggle_settings": "تغییر تنظیمات",
|
||||
"toggle_theme": "تغییر تم تاریک",
|
||||
"total_usage": "استفاده کلی",
|
||||
"trash": "سطل زباله",
|
||||
"toggle_settings": "",
|
||||
"toggle_theme": "",
|
||||
"total_usage": "",
|
||||
"trash": "",
|
||||
"trash_all": "",
|
||||
"trash_count": "",
|
||||
"trash_no_results_message": "",
|
||||
"trashed_items_will_be_permanently_deleted_after": "",
|
||||
"type": "نوع",
|
||||
"type": "",
|
||||
"unarchive": "",
|
||||
"unfavorite": "حذف از علاقهمندیها",
|
||||
"unhide_person": "آشکار کردن فرد",
|
||||
"unknown": "ناشناخته",
|
||||
"unknown_year": "سال نامشخص",
|
||||
"unlimited": "نامحدود",
|
||||
"unlink_oauth": "لغو اتصال OAuth",
|
||||
"unfavorite": "",
|
||||
"unhide_person": "",
|
||||
"unknown": "",
|
||||
"unknown_year": "",
|
||||
"unlimited": "",
|
||||
"unlink_oauth": "",
|
||||
"unlinked_oauth_account": "",
|
||||
"unnamed_album": "آلبوم بدون نام",
|
||||
"unnamed_share": "اشتراک بدون نام",
|
||||
"unselect_all": "لغو انتخاب همه",
|
||||
"unnamed_album": "",
|
||||
"unnamed_share": "",
|
||||
"unselect_all": "",
|
||||
"unstack": "",
|
||||
"untracked_files": "",
|
||||
"untracked_files_decription": "",
|
||||
"up_next": "مورد بعدی",
|
||||
"up_next": "",
|
||||
"updated_password": "",
|
||||
"upload": "آپلود",
|
||||
"upload_concurrency": "تعداد آپلود همزمان",
|
||||
"url": "آدرس",
|
||||
"usage": "استفاده",
|
||||
"user": "کاربر",
|
||||
"user_id": "شناسه کاربر",
|
||||
"user_usage_detail": "جزئیات استفاده کاربر",
|
||||
"username": "نام کاربری",
|
||||
"users": "کاربران",
|
||||
"utilities": "ابزارها",
|
||||
"validate": "اعتبارسنجی",
|
||||
"variables": "متغیرها",
|
||||
"version": "نسخه",
|
||||
"upload": "",
|
||||
"upload_concurrency": "",
|
||||
"url": "",
|
||||
"usage": "",
|
||||
"user": "",
|
||||
"user_id": "",
|
||||
"user_usage_detail": "",
|
||||
"username": "",
|
||||
"users": "",
|
||||
"utilities": "",
|
||||
"validate": "",
|
||||
"variables": "",
|
||||
"version": "",
|
||||
"version_announcement_message": "",
|
||||
"video": "ویدیو",
|
||||
"video": "",
|
||||
"video_hover_setting": "",
|
||||
"video_hover_setting_description": "",
|
||||
"videos": "ویدیوها",
|
||||
"videos": "",
|
||||
"videos_count": "",
|
||||
"view": "مشاهده",
|
||||
"view_all": "مشاهده همه",
|
||||
"view_all_users": "مشاهده همه کاربران",
|
||||
"view_links": "مشاهده لینکها",
|
||||
"view_next_asset": "مشاهده محتوای بعدی",
|
||||
"view_previous_asset": "مشاهده محتوای قبلی",
|
||||
"waiting": "در انتظار",
|
||||
"week": "هفته",
|
||||
"welcome": "خوش آمدید",
|
||||
"view": "",
|
||||
"view_all": "",
|
||||
"view_all_users": "",
|
||||
"view_links": "",
|
||||
"view_next_asset": "",
|
||||
"view_previous_asset": "",
|
||||
"waiting": "",
|
||||
"week": "",
|
||||
"welcome": "",
|
||||
"welcome_to_immich": "",
|
||||
"year": "سال",
|
||||
"yes": "بله",
|
||||
"year": "",
|
||||
"yes": "",
|
||||
"you_dont_have_any_shared_links": "",
|
||||
"zoom_image": "بزرگنمایی تصویر"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.126.1"
|
||||
version = "1.125.7"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 184,
|
||||
"android.injected.version.name" => "1.126.1",
|
||||
"android.injected.version.code" => 182,
|
||||
"android.injected.version.name" => "1.125.7",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -541,7 +541,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -685,7 +685,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -715,7 +715,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -748,7 +748,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -791,7 +791,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -831,7 +831,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 193;
|
||||
CURRENT_PROJECT_VERSION = 190;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.126.0</string>
|
||||
<string>1.125.2</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -93,7 +93,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>193</string>
|
||||
<string>190</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
increment_version_number(
|
||||
version_number: "1.126.1"
|
||||
version_number: "1.125.7"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -10,7 +10,6 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
addDefault(value, 'ratings', RatingsResponse().toJson());
|
||||
addDefault(value, 'people', PeopleResponse().toJson());
|
||||
addDefault(value, 'tags', TagsResponse().toJson());
|
||||
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
|
||||
}
|
||||
break;
|
||||
case 'ServerConfigDto':
|
||||
|
||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.126.1
|
||||
- API version: 1.125.7
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
@@ -408,8 +408,6 @@ Class | Method | HTTP request | Description
|
||||
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
|
||||
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
|
||||
- [SharedLinkType](doc//SharedLinkType.md)
|
||||
- [SharedLinksResponse](doc//SharedLinksResponse.md)
|
||||
- [SharedLinksUpdate](doc//SharedLinksUpdate.md)
|
||||
- [SignUpDto](doc//SignUpDto.md)
|
||||
- [SmartSearchDto](doc//SmartSearchDto.md)
|
||||
- [SourceType](doc//SourceType.md)
|
||||
|
||||
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@@ -221,8 +221,6 @@ part 'model/shared_link_create_dto.dart';
|
||||
part 'model/shared_link_edit_dto.dart';
|
||||
part 'model/shared_link_response_dto.dart';
|
||||
part 'model/shared_link_type.dart';
|
||||
part 'model/shared_links_response.dart';
|
||||
part 'model/shared_links_update.dart';
|
||||
part 'model/sign_up_dto.dart';
|
||||
part 'model/smart_search_dto.dart';
|
||||
part 'model/source_type.dart';
|
||||
|
||||
16
mobile/openapi/lib/api/shared_links_api.dart
generated
16
mobile/openapi/lib/api/shared_links_api.dart
generated
@@ -127,10 +127,7 @@ class SharedLinksApi {
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /shared-links' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] albumId:
|
||||
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, }) async {
|
||||
Future<Response> getAllSharedLinksWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/shared-links';
|
||||
|
||||
@@ -141,10 +138,6 @@ class SharedLinksApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (albumId != null) {
|
||||
queryParams.addAll(_queryParams('', 'albumId', albumId));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -159,11 +152,8 @@ class SharedLinksApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] albumId:
|
||||
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, }) async {
|
||||
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, );
|
||||
Future<List<SharedLinkResponseDto>?> getAllSharedLinks() async {
|
||||
final response = await getAllSharedLinksWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@@ -496,10 +496,6 @@ class ApiClient {
|
||||
return SharedLinkResponseDto.fromJson(value);
|
||||
case 'SharedLinkType':
|
||||
return SharedLinkTypeTypeTransformer().decode(value);
|
||||
case 'SharedLinksResponse':
|
||||
return SharedLinksResponse.fromJson(value);
|
||||
case 'SharedLinksUpdate':
|
||||
return SharedLinksUpdate.fromJson(value);
|
||||
case 'SignUpDto':
|
||||
return SignUpDto.fromJson(value);
|
||||
case 'SmartSearchDto':
|
||||
|
||||
13
mobile/openapi/lib/model/people_update_item.dart
generated
13
mobile/openapi/lib/model/people_update_item.dart
generated
@@ -14,7 +14,6 @@ class PeopleUpdateItem {
|
||||
/// Returns a new [PeopleUpdateItem] instance.
|
||||
PeopleUpdateItem({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.featureFaceAssetId,
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
@@ -25,8 +24,6 @@ class PeopleUpdateItem {
|
||||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
/// Asset is used to get the feature face thumbnail.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -68,7 +65,6 @@ class PeopleUpdateItem {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.featureFaceAssetId == featureFaceAssetId &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
@@ -79,7 +75,6 @@ class PeopleUpdateItem {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
@@ -87,7 +82,7 @@ class PeopleUpdateItem {
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -96,11 +91,6 @@ class PeopleUpdateItem {
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.featureFaceAssetId != null) {
|
||||
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
|
||||
} else {
|
||||
@@ -135,7 +125,6 @@ class PeopleUpdateItem {
|
||||
|
||||
return PeopleUpdateItem(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
|
||||
13
mobile/openapi/lib/model/person_create_dto.dart
generated
13
mobile/openapi/lib/model/person_create_dto.dart
generated
@@ -14,7 +14,6 @@ class PersonCreateDto {
|
||||
/// Returns a new [PersonCreateDto] instance.
|
||||
PersonCreateDto({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.isFavorite,
|
||||
this.isHidden,
|
||||
this.name,
|
||||
@@ -23,8 +22,6 @@ class PersonCreateDto {
|
||||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
///
|
||||
/// 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
|
||||
@@ -54,7 +51,6 @@ class PersonCreateDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name;
|
||||
@@ -63,13 +59,12 @@ class PersonCreateDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden == null ? 0 : isHidden!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -78,11 +73,6 @@ class PersonCreateDto {
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
@@ -111,7 +101,6 @@ class PersonCreateDto {
|
||||
|
||||
return PersonCreateDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden'),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
|
||||
20
mobile/openapi/lib/model/person_response_dto.dart
generated
20
mobile/openapi/lib/model/person_response_dto.dart
generated
@@ -14,7 +14,6 @@ class PersonResponseDto {
|
||||
/// Returns a new [PersonResponseDto] instance.
|
||||
PersonResponseDto({
|
||||
required this.birthDate,
|
||||
this.color,
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
required this.isHidden,
|
||||
@@ -25,15 +24,6 @@ class PersonResponseDto {
|
||||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? color;
|
||||
|
||||
String id;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
@@ -63,7 +53,6 @@ class PersonResponseDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
@@ -75,7 +64,6 @@ class PersonResponseDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden.hashCode) +
|
||||
@@ -84,7 +72,7 @@ class PersonResponseDto {
|
||||
(updatedAt == null ? 0 : updatedAt!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -92,11 +80,6 @@ class PersonResponseDto {
|
||||
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
if (this.isFavorite != null) {
|
||||
@@ -125,7 +108,6 @@ class PersonResponseDto {
|
||||
|
||||
return PersonResponseDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
|
||||
|
||||
13
mobile/openapi/lib/model/person_update_dto.dart
generated
13
mobile/openapi/lib/model/person_update_dto.dart
generated
@@ -14,7 +14,6 @@ class PersonUpdateDto {
|
||||
/// Returns a new [PersonUpdateDto] instance.
|
||||
PersonUpdateDto({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.featureFaceAssetId,
|
||||
this.isFavorite,
|
||||
this.isHidden,
|
||||
@@ -24,8 +23,6 @@ class PersonUpdateDto {
|
||||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
/// Asset is used to get the feature face thumbnail.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -64,7 +61,6 @@ class PersonUpdateDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.featureFaceAssetId == featureFaceAssetId &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
@@ -74,14 +70,13 @@ class PersonUpdateDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden == null ? 0 : isHidden!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -90,11 +85,6 @@ class PersonUpdateDto {
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.featureFaceAssetId != null) {
|
||||
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
|
||||
} else {
|
||||
@@ -128,7 +118,6 @@ class PersonUpdateDto {
|
||||
|
||||
return PersonUpdateDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden'),
|
||||
|
||||
@@ -14,7 +14,6 @@ class PersonWithFacesResponseDto {
|
||||
/// Returns a new [PersonWithFacesResponseDto] instance.
|
||||
PersonWithFacesResponseDto({
|
||||
required this.birthDate,
|
||||
this.color,
|
||||
this.faces = const [],
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
@@ -26,15 +25,6 @@ class PersonWithFacesResponseDto {
|
||||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? color;
|
||||
|
||||
List<AssetFaceWithoutPersonResponseDto> faces;
|
||||
|
||||
String id;
|
||||
@@ -66,7 +56,6 @@ class PersonWithFacesResponseDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
_deepEquality.equals(other.faces, faces) &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
@@ -79,7 +68,6 @@ class PersonWithFacesResponseDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(faces.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
@@ -89,7 +77,7 @@ class PersonWithFacesResponseDto {
|
||||
(updatedAt == null ? 0 : updatedAt!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -97,11 +85,6 @@ class PersonWithFacesResponseDto {
|
||||
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
json[r'faces'] = this.faces;
|
||||
json[r'id'] = this.id;
|
||||
@@ -131,7 +114,6 @@ class PersonWithFacesResponseDto {
|
||||
|
||||
return PersonWithFacesResponseDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
|
||||
107
mobile/openapi/lib/model/shared_links_response.dart
generated
107
mobile/openapi/lib/model/shared_links_response.dart
generated
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SharedLinksResponse {
|
||||
/// Returns a new [SharedLinksResponse] instance.
|
||||
SharedLinksResponse({
|
||||
this.enabled = true,
|
||||
this.sidebarWeb = false,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
bool sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SharedLinksResponse &&
|
||||
other.enabled == enabled &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(sidebarWeb.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinksResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SharedLinksResponse] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SharedLinksResponse? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SharedLinksResponse");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SharedLinksResponse(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SharedLinksResponse> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SharedLinksResponse>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SharedLinksResponse.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SharedLinksResponse> mapFromJson(dynamic json) {
|
||||
final map = <String, SharedLinksResponse>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SharedLinksResponse.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SharedLinksResponse-objects as value to a dart map
|
||||
static Map<String, List<SharedLinksResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SharedLinksResponse>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SharedLinksResponse.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'sidebarWeb',
|
||||
};
|
||||
}
|
||||
|
||||
125
mobile/openapi/lib/model/shared_links_update.dart
generated
125
mobile/openapi/lib/model/shared_links_update.dart
generated
@@ -1,125 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SharedLinksUpdate {
|
||||
/// Returns a new [SharedLinksUpdate] instance.
|
||||
SharedLinksUpdate({
|
||||
this.enabled,
|
||||
this.sidebarWeb,
|
||||
});
|
||||
|
||||
///
|
||||
/// 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? enabled;
|
||||
|
||||
///
|
||||
/// 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? sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SharedLinksUpdate &&
|
||||
other.enabled == enabled &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinksUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.enabled != null) {
|
||||
json[r'enabled'] = this.enabled;
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
if (this.sidebarWeb != null) {
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
} else {
|
||||
// json[r'sidebarWeb'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SharedLinksUpdate] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SharedLinksUpdate? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SharedLinksUpdate");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SharedLinksUpdate(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SharedLinksUpdate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SharedLinksUpdate>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SharedLinksUpdate.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SharedLinksUpdate> mapFromJson(dynamic json) {
|
||||
final map = <String, SharedLinksUpdate>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SharedLinksUpdate.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SharedLinksUpdate-objects as value to a dart map
|
||||
static Map<String, List<SharedLinksUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SharedLinksUpdate>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SharedLinksUpdate.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class UserPreferencesResponseDto {
|
||||
required this.people,
|
||||
required this.purchase,
|
||||
required this.ratings,
|
||||
required this.sharedLinks,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
@@ -41,8 +40,6 @@ class UserPreferencesResponseDto {
|
||||
|
||||
RatingsResponse ratings;
|
||||
|
||||
SharedLinksResponse sharedLinks;
|
||||
|
||||
TagsResponse tags;
|
||||
|
||||
@override
|
||||
@@ -55,7 +52,6 @@ class UserPreferencesResponseDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@override
|
||||
@@ -69,11 +65,10 @@ class UserPreferencesResponseDto {
|
||||
(people.hashCode) +
|
||||
(purchase.hashCode) +
|
||||
(ratings.hashCode) +
|
||||
(sharedLinks.hashCode) +
|
||||
(tags.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -85,7 +80,6 @@ class UserPreferencesResponseDto {
|
||||
json[r'people'] = this.people;
|
||||
json[r'purchase'] = this.purchase;
|
||||
json[r'ratings'] = this.ratings;
|
||||
json[r'sharedLinks'] = this.sharedLinks;
|
||||
json[r'tags'] = this.tags;
|
||||
return json;
|
||||
}
|
||||
@@ -107,7 +101,6 @@ class UserPreferencesResponseDto {
|
||||
people: PeopleResponse.fromJson(json[r'people'])!,
|
||||
purchase: PurchaseResponse.fromJson(json[r'purchase'])!,
|
||||
ratings: RatingsResponse.fromJson(json[r'ratings'])!,
|
||||
sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!,
|
||||
tags: TagsResponse.fromJson(json[r'tags'])!,
|
||||
);
|
||||
}
|
||||
@@ -164,7 +157,6 @@ class UserPreferencesResponseDto {
|
||||
'people',
|
||||
'purchase',
|
||||
'ratings',
|
||||
'sharedLinks',
|
||||
'tags',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class UserPreferencesUpdateDto {
|
||||
this.people,
|
||||
this.purchase,
|
||||
this.ratings,
|
||||
this.sharedLinks,
|
||||
this.tags,
|
||||
});
|
||||
|
||||
@@ -89,14 +88,6 @@ class UserPreferencesUpdateDto {
|
||||
///
|
||||
RatingsUpdate? ratings;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
SharedLinksUpdate? sharedLinks;
|
||||
|
||||
///
|
||||
/// 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
|
||||
@@ -115,7 +106,6 @@ class UserPreferencesUpdateDto {
|
||||
other.people == people &&
|
||||
other.purchase == purchase &&
|
||||
other.ratings == ratings &&
|
||||
other.sharedLinks == sharedLinks &&
|
||||
other.tags == tags;
|
||||
|
||||
@override
|
||||
@@ -129,11 +119,10 @@ class UserPreferencesUpdateDto {
|
||||
(people == null ? 0 : people!.hashCode) +
|
||||
(purchase == null ? 0 : purchase!.hashCode) +
|
||||
(ratings == null ? 0 : ratings!.hashCode) +
|
||||
(sharedLinks == null ? 0 : sharedLinks!.hashCode) +
|
||||
(tags == null ? 0 : tags!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -177,11 +166,6 @@ class UserPreferencesUpdateDto {
|
||||
} else {
|
||||
// json[r'ratings'] = null;
|
||||
}
|
||||
if (this.sharedLinks != null) {
|
||||
json[r'sharedLinks'] = this.sharedLinks;
|
||||
} else {
|
||||
// json[r'sharedLinks'] = null;
|
||||
}
|
||||
if (this.tags != null) {
|
||||
json[r'tags'] = this.tags;
|
||||
} else {
|
||||
@@ -207,7 +191,6 @@ class UserPreferencesUpdateDto {
|
||||
people: PeopleUpdate.fromJson(json[r'people']),
|
||||
purchase: PurchaseUpdate.fromJson(json[r'purchase']),
|
||||
ratings: RatingsUpdate.fromJson(json[r'ratings']),
|
||||
sharedLinks: SharedLinksUpdate.fromJson(json[r'sharedLinks']),
|
||||
tags: TagsUpdate.fromJson(json[r'tags']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.126.1+184
|
||||
version: 1.125.7+182
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
@@ -5230,17 +5230,7 @@
|
||||
"/shared-links": {
|
||||
"get": {
|
||||
"operationId": "getAllSharedLinks",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "albumId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
@@ -7468,7 +7458,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -10296,10 +10286,6 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"type": "string"
|
||||
@@ -10416,10 +10402,6 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -10441,10 +10423,6 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10495,10 +10473,6 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"type": "string"
|
||||
@@ -10524,10 +10498,6 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "string"
|
||||
},
|
||||
"faces": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto"
|
||||
@@ -11524,34 +11494,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SharedLinksResponse": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"sidebarWeb"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SharedLinksUpdate": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SignUpDto": {
|
||||
"properties": {
|
||||
"email": {
|
||||
@@ -12669,6 +12611,7 @@
|
||||
"properties": {
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -13198,9 +13141,6 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsResponse"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksResponse"
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/components/schemas/TagsResponse"
|
||||
}
|
||||
@@ -13214,7 +13154,6 @@
|
||||
"people",
|
||||
"purchase",
|
||||
"ratings",
|
||||
"sharedLinks",
|
||||
"tags"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -13245,9 +13184,6 @@
|
||||
"ratings": {
|
||||
"$ref": "#/components/schemas/RatingsUpdate"
|
||||
},
|
||||
"sharedLinks": {
|
||||
"$ref": "#/components/schemas/SharedLinksUpdate"
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/components/schemas/TagsUpdate"
|
||||
}
|
||||
|
||||
12
open-api/typescript-sdk/package-lock.json
generated
12
open-api/typescript-sdk/package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -22,9 +22,9 @@
|
||||
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
||||
"version": "22.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.9.tgz",
|
||||
"integrity": "sha512-Ir6hwgsKyNESl/gLOcEz3krR4CBGgliDqBQ2ma4wIhEx0w+xnoeTq3tdrNw15kU3SxogDjOgv9sqdtLW8mIHaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.126.1
|
||||
* 1.125.7
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -113,10 +113,6 @@ export type PurchaseResponse = {
|
||||
export type RatingsResponse = {
|
||||
enabled: boolean;
|
||||
};
|
||||
export type SharedLinksResponse = {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
export type TagsResponse = {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
@@ -130,7 +126,6 @@ export type UserPreferencesResponseDto = {
|
||||
people: PeopleResponse;
|
||||
purchase: PurchaseResponse;
|
||||
ratings: RatingsResponse;
|
||||
sharedLinks: SharedLinksResponse;
|
||||
tags: TagsResponse;
|
||||
};
|
||||
export type AvatarUpdate = {
|
||||
@@ -163,10 +158,6 @@ export type PurchaseUpdate = {
|
||||
export type RatingsUpdate = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type SharedLinksUpdate = {
|
||||
enabled?: boolean;
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
export type TagsUpdate = {
|
||||
enabled?: boolean;
|
||||
sidebarWeb?: boolean;
|
||||
@@ -180,7 +171,6 @@ export type UserPreferencesUpdateDto = {
|
||||
people?: PeopleUpdate;
|
||||
purchase?: PurchaseUpdate;
|
||||
ratings?: RatingsUpdate;
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
tags?: TagsUpdate;
|
||||
};
|
||||
export type AlbumUserResponseDto = {
|
||||
@@ -223,8 +213,6 @@ export type AssetFaceWithoutPersonResponseDto = {
|
||||
};
|
||||
export type PersonWithFacesResponseDto = {
|
||||
birthDate: string | null;
|
||||
/** This property was added in v1.126.0 */
|
||||
color?: string;
|
||||
faces: AssetFaceWithoutPersonResponseDto[];
|
||||
id: string;
|
||||
/** This property was added in v1.126.0 */
|
||||
@@ -505,8 +493,6 @@ export type DuplicateResponseDto = {
|
||||
};
|
||||
export type PersonResponseDto = {
|
||||
birthDate: string | null;
|
||||
/** This property was added in v1.126.0 */
|
||||
color?: string;
|
||||
id: string;
|
||||
/** This property was added in v1.126.0 */
|
||||
isFavorite?: boolean;
|
||||
@@ -707,7 +693,6 @@ export type PersonCreateDto = {
|
||||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
@@ -718,7 +703,6 @@ export type PeopleUpdateItem = {
|
||||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
/** Asset is used to get the feature face thumbnail. */
|
||||
featureFaceAssetId?: string;
|
||||
/** Person id. */
|
||||
@@ -736,7 +720,6 @@ export type PersonUpdateDto = {
|
||||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
/** Asset is used to get the feature face thumbnail. */
|
||||
featureFaceAssetId?: string;
|
||||
isFavorite?: boolean;
|
||||
@@ -2762,15 +2745,11 @@ export function deleteSession({ id }: {
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
export function getAllSharedLinks({ albumId }: {
|
||||
albumId?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: SharedLinkResponseDto[];
|
||||
}>(`/shared-links${QS.query(QS.explode({
|
||||
albumId
|
||||
}))}`, {
|
||||
}>("/shared-links", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20250204@sha256:8b203f19f4d5cf4619b60ee5f50d6d4b5ea3745747f5e5170d1b7404ebeb0792 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20250123@sha256:04eba5cd87d61bc3d20a3915b2302f04d08fbc329c55ee0cde103c502f59f412 AS dev
|
||||
|
||||
RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
@@ -42,7 +42,7 @@ RUN npm run build
|
||||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20250204@sha256:2af3da713d5ab3ccca23b216b747557ea6016117e72deac101e35069ccaf9b5e
|
||||
FROM ghcr.io/immich-app/base-server-prod:20250123@sha256:591739983913f82672d8191258f3a1a24c123db0d619ff91fca8fef431ee1338
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
3095
server/package-lock.json
generated
3095
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.126.1",
|
||||
"version": "1.125.7",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -35,19 +35,16 @@
|
||||
"email:dev": "email dev -p 3050 --dir src/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@nestjs/apollo": "^13.0.2",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.0",
|
||||
"@nestjs/common": "^10.2.2",
|
||||
"@nestjs/core": "^10.2.2",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/graphql": "^13.0.2",
|
||||
"@nestjs/platform-express": "^11.0.4",
|
||||
"@nestjs/platform-socket.io": "^11.0.4",
|
||||
"@nestjs/platform-express": "^10.2.2",
|
||||
"@nestjs/platform-socket.io": "^10.2.2",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/swagger": "^11.0.2",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@nestjs/swagger": "^8.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.24.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.0",
|
||||
@@ -63,10 +60,9 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exiftool-vendored": "^28.3.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-glob": "github:etnoy/fast-glob#built",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
"graphql": "^16.10.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"i18n-iso-countries": "^7.6.0",
|
||||
"ioredis": "^5.3.2",
|
||||
@@ -76,9 +72,9 @@
|
||||
"kysely-postgres-js": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"nest-commander": "^3.16.0",
|
||||
"nestjs-cls": "^5.0.0",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nest-commander": "^3.11.1",
|
||||
"nestjs-cls": "^4.3.0",
|
||||
"nestjs-kysely": "^1.0.0",
|
||||
"nestjs-otel": "^6.0.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"openid-client": "^5.4.3",
|
||||
@@ -101,9 +97,9 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.4",
|
||||
"@nestjs/cli": "^10.1.16",
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/testing": "^10.2.2",
|
||||
"@swc/core": "^1.4.14",
|
||||
"@testcontainers/postgresql": "^10.2.1",
|
||||
"@types/archiver": "^6.0.0",
|
||||
@@ -116,7 +112,7 @@
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/node": "^22.10.9",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/picomatch": "^3.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { KyselyModule } from 'nestjs-kysely';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
import { commands } from 'src/commands';
|
||||
import { IWorker } from 'src/constants';
|
||||
@@ -27,7 +24,6 @@ import { providers, repositories } from 'src/repositories';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { resolvers } from 'src/resolvers';
|
||||
import { services } from 'src/services';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
@@ -108,28 +104,9 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
...imports,
|
||||
ScheduleModule.forRoot(),
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
playground: true,
|
||||
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
||||
sortSchema: true,
|
||||
debug: true,
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
}),
|
||||
],
|
||||
imports: [...imports, ScheduleModule.forRoot()],
|
||||
controllers: [...controllers],
|
||||
providers: [
|
||||
//
|
||||
...common,
|
||||
...middleware,
|
||||
...resolvers,
|
||||
{ provide: IWorker, useValue: ImmichWorker.API },
|
||||
],
|
||||
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
|
||||
})
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const citiesFile = 'cities500.txt';
|
||||
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
||||
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
||||
|
||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico', '/graphql'];
|
||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SharedLinkEditDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
import { ImmichCookie, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
@@ -25,8 +24,8 @@ export class SharedLinkController {
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.SHARED_LINK_READ })
|
||||
getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
|
||||
return this.service.getAll(auth, dto);
|
||||
getAllSharedLinks(@Auth() auth: AuthDto): Promise<SharedLinkResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
@@ -39,21 +38,8 @@ export class UserController {
|
||||
|
||||
@Get()
|
||||
@Authenticated()
|
||||
async searchUsers(@Req() req: Request): Promise<UserResponseDto[]> {
|
||||
const response = await fetch(`http://localhost:2283/graphql`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
operationName: null,
|
||||
query: '{ users { id name email } }',
|
||||
}),
|
||||
headers: {
|
||||
...req.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await response.json();
|
||||
return data.users;
|
||||
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
|
||||
return this.service.search(auth);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
|
||||
@@ -5,13 +5,12 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IConfigRepository, ILoggingRepository } from 'src/types';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
@@ -34,24 +33,24 @@ let instance: StorageCore | null;
|
||||
export class StorageCore {
|
||||
private constructor(
|
||||
private assetRepository: IAssetRepository,
|
||||
private configRepository: ConfigRepository,
|
||||
private cryptoRepository: CryptoRepository,
|
||||
private configRepository: IConfigRepository,
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
private moveRepository: IMoveRepository,
|
||||
private personRepository: IPersonRepository,
|
||||
private storageRepository: IStorageRepository,
|
||||
private systemMetadataRepository: SystemMetadataRepository,
|
||||
private logger: LoggingRepository,
|
||||
private systemMetadataRepository: ISystemMetadataRepository,
|
||||
private logger: ILoggingRepository,
|
||||
) {}
|
||||
|
||||
static create(
|
||||
assetRepository: IAssetRepository,
|
||||
configRepository: ConfigRepository,
|
||||
cryptoRepository: CryptoRepository,
|
||||
configRepository: IConfigRepository,
|
||||
cryptoRepository: ICryptoRepository,
|
||||
moveRepository: IMoveRepository,
|
||||
personRepository: IPersonRepository,
|
||||
storageRepository: IStorageRepository,
|
||||
systemMetadataRepository: SystemMetadataRepository,
|
||||
logger: LoggingRepository,
|
||||
systemMetadataRepository: ISystemMetadataRepository,
|
||||
logger: ILoggingRepository,
|
||||
) {
|
||||
if (!instance) {
|
||||
instance = new StorageCore(
|
||||
|
||||
1
server/src/db.d.ts
vendored
1
server/src/db.d.ts
vendored
@@ -276,7 +276,6 @@ export interface Partners {
|
||||
|
||||
export interface Person {
|
||||
birthDate: Timestamp | null;
|
||||
color: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
faceAssetId: string | null;
|
||||
id: Generated<string>;
|
||||
|
||||
@@ -7,14 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SourceType } from 'src/enum';
|
||||
import {
|
||||
IsDateStringFormat,
|
||||
MaxDateString,
|
||||
Optional,
|
||||
ValidateBoolean,
|
||||
ValidateHexColor,
|
||||
ValidateUUID,
|
||||
} from 'src/validation';
|
||||
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class PersonCreateDto {
|
||||
/**
|
||||
@@ -42,10 +35,6 @@ export class PersonCreateDto {
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ValidateHexColor()
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class PersonUpdateDto extends PersonCreateDto {
|
||||
@@ -113,8 +102,6 @@ export class PersonResponseDto {
|
||||
updatedAt?: Date;
|
||||
@PropertyLifecycle({ addedAt: 'v1.126.0' })
|
||||
isFavorite?: boolean;
|
||||
@PropertyLifecycle({ addedAt: 'v1.126.0' })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
@@ -189,7 +176,6 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
color: person.color ?? undefined,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SessionItem } from 'src/types';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
|
||||
export class SessionResponseDto {
|
||||
id!: string;
|
||||
@@ -9,7 +9,7 @@ export class SessionResponseDto {
|
||||
deviceOS!: string;
|
||||
}
|
||||
|
||||
export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({
|
||||
export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
|
||||
@@ -7,11 +7,6 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class SharedLinkSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
albumId?: string;
|
||||
}
|
||||
|
||||
export class SharedLinkCreateDto {
|
||||
@IsEnum(SharedLinkType)
|
||||
@ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TagCreateDto {
|
||||
@IsString()
|
||||
@@ -17,8 +18,9 @@ export class TagCreateDto {
|
||||
}
|
||||
|
||||
export class TagUpdateDto {
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ValidateHexColor()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsHexColor()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,14 +38,6 @@ class PeopleUpdate {
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class SharedLinksUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class TagsUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
enabled?: boolean;
|
||||
@@ -106,11 +98,6 @@ export class UserPreferencesUpdateDto {
|
||||
@Type(() => RatingsUpdate)
|
||||
ratings?: RatingsUpdate;
|
||||
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => SharedLinksUpdate)
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => TagsUpdate)
|
||||
@@ -165,11 +152,6 @@ class TagsResponse {
|
||||
sidebarWeb: boolean = true;
|
||||
}
|
||||
|
||||
class SharedLinksResponse {
|
||||
enabled: boolean = true;
|
||||
sidebarWeb: boolean = false;
|
||||
}
|
||||
|
||||
class EmailNotificationsResponse {
|
||||
enabled!: boolean;
|
||||
albumInvite!: boolean;
|
||||
@@ -193,7 +175,6 @@ export class UserPreferencesResponseDto implements UserPreferences {
|
||||
memories!: MemoriesResponse;
|
||||
people!: PeopleResponse;
|
||||
ratings!: RatingsResponse;
|
||||
sharedLinks!: SharedLinksResponse;
|
||||
tags!: TagsResponse;
|
||||
avatar!: AvatarResponse;
|
||||
emailNotifications!: EmailNotificationsResponse;
|
||||
|
||||
@@ -52,7 +52,4 @@ export class PersonEntity {
|
||||
|
||||
@Column({ default: false })
|
||||
isFavorite!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, default: null })
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ export interface UserPreferences {
|
||||
ratings: {
|
||||
enabled: boolean;
|
||||
};
|
||||
sharedLinks: {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
tags: {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
@@ -78,10 +74,6 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
},
|
||||
ratings: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
25
server/src/interfaces/process.interface.ts
Normal file
25
server/src/interfaces/process.interface.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export interface ImmichReadStream {
|
||||
stream: Readable;
|
||||
type?: string;
|
||||
length?: number;
|
||||
}
|
||||
|
||||
export interface ImmichZipStream extends ImmichReadStream {
|
||||
addFile: (inputPath: string, filename: string) => void;
|
||||
finalize: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
available: number;
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const IProcessRepository = 'IProcessRepository';
|
||||
|
||||
export interface IProcessRepository {
|
||||
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams;
|
||||
}
|
||||
17
server/src/interfaces/session.interface.ts
Normal file
17
server/src/interfaces/session.interface.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Insertable, Updateable } from 'kysely';
|
||||
import { Sessions } from 'src/db';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
|
||||
export const ISessionRepository = 'ISessionRepository';
|
||||
|
||||
type E = SessionEntity;
|
||||
export type SessionSearchOptions = { updatedBefore: Date };
|
||||
|
||||
export interface ISessionRepository {
|
||||
search(options: SessionSearchOptions): Promise<SessionEntity[]>;
|
||||
create(dto: Insertable<Sessions>): Promise<SessionEntity>;
|
||||
update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
getByToken(token: string): Promise<E | undefined>;
|
||||
getByUserId(userId: string): Promise<E[]>;
|
||||
}
|
||||
@@ -4,13 +4,8 @@ import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
|
||||
export const ISharedLinkRepository = 'ISharedLinkRepository';
|
||||
|
||||
export type SharedLinkSearchOptions = {
|
||||
userId: string;
|
||||
albumId?: string;
|
||||
};
|
||||
|
||||
export interface ISharedLinkRepository {
|
||||
getAll(options: SharedLinkSearchOptions): Promise<SharedLinkEntity[]>;
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]>;
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | undefined>;
|
||||
getByKey(key: Buffer): Promise<SharedLinkEntity | undefined>;
|
||||
create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity>;
|
||||
|
||||
10
server/src/interfaces/system-metadata.interface.ts
Normal file
10
server/src/interfaces/system-metadata.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SystemMetadata } from 'src/entities/system-metadata.entity';
|
||||
|
||||
export const ISystemMetadataRepository = 'ISystemMetadataRepository';
|
||||
|
||||
export interface ISystemMetadataRepository {
|
||||
get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>;
|
||||
set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>;
|
||||
delete<T extends keyof SystemMetadata>(key: T): Promise<void>;
|
||||
readFile(filename: string): Promise<string>;
|
||||
}
|
||||
@@ -5,18 +5,19 @@ import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-re
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { fromMaybeArray, getReqRes } from 'src/utils/request';
|
||||
import { fromMaybeArray } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadInterceptor implements NestInterceptor {
|
||||
constructor(private service: AssetMediaService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const { type, req, res } = getReqRes<AuthenticatedRequest, Response<AssetMediaResponseDto>>(context);
|
||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
||||
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
|
||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
||||
if (response && type === 'http') {
|
||||
if (response) {
|
||||
res.status(200);
|
||||
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichQuery, MetadataKey, Permission } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AuthService, LoginDetails } from 'src/services/auth.service';
|
||||
import { getReqRes } from 'src/utils/request';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
type AdminRoute = { admin?: true };
|
||||
@@ -36,8 +35,7 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
|
||||
};
|
||||
|
||||
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
|
||||
const { req } = getReqRes<AuthenticatedRequest>(context);
|
||||
return req.user;
|
||||
return context.switchToHttp().getRequest<AuthenticatedRequest>().user;
|
||||
});
|
||||
|
||||
export const FileResponse = () =>
|
||||
@@ -88,12 +86,12 @@ export class AuthGuard implements CanActivate {
|
||||
sharedLink: sharedLinkRoute,
|
||||
permission,
|
||||
} = { sharedLink: false, admin: false, ...options };
|
||||
const { req } = getReqRes<AuthenticatedRequest>(context);
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
|
||||
req.user = await this.authService.authenticate({
|
||||
headers: req.headers,
|
||||
queryParams: req.query as Record<string, string>,
|
||||
metadata: { adminRoute, sharedLinkRoute, permission, uri: req.path },
|
||||
request.user = await this.authService.authenticate({
|
||||
headers: request.headers,
|
||||
queryParams: request.query as Record<string, string>,
|
||||
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { logGlobalError } from 'src/utils/logger';
|
||||
import { getReqRes } from 'src/utils/request';
|
||||
|
||||
type StructuredError = {
|
||||
status: number;
|
||||
body: {
|
||||
[key: string]: unknown;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
@@ -24,20 +14,15 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
}
|
||||
|
||||
catch(error: Error, host: ArgumentsHost) {
|
||||
const { res } = getReqRes(host);
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const { status, body } = this.fromError(error);
|
||||
const message = { ...body, statusCode: status, correlationId: this.cls.getId() };
|
||||
|
||||
if (host.getType<GqlContextType>() === 'graphql') {
|
||||
throw new GraphQLError(body?.message || 'Error', { extensions: message });
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(status).json(message);
|
||||
if (!response.headersSent) {
|
||||
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
|
||||
}
|
||||
}
|
||||
|
||||
private fromError(error: Error): StructuredError {
|
||||
private fromError(error: Error) {
|
||||
logGlobalError(this.logger, error);
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
@@ -49,7 +34,7 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
body = { message: body };
|
||||
}
|
||||
|
||||
return { status, body } as StructuredError;
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { Observable, finalize } from 'rxjs';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { getReqRes } from 'src/utils/request';
|
||||
|
||||
const maxArrayLength = 100;
|
||||
const replacer = (key: string, value: unknown) => {
|
||||
@@ -23,7 +23,10 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
const { req, res } = getReqRes(context);
|
||||
const handler = context.switchToHttp();
|
||||
const req = handler.getRequest<Request>();
|
||||
const res = handler.getResponse<Response>();
|
||||
|
||||
const { method, ip, url } = req;
|
||||
|
||||
const start = performance.now();
|
||||
@@ -32,7 +35,9 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
finalize(() => {
|
||||
const finish = performance.now();
|
||||
const duration = (finish - start).toFixed(2);
|
||||
this.logger.debug(`${method} ${url} ${res?.statusCode || ''} ${duration}ms ${ip}`);
|
||||
const { statusCode } = res;
|
||||
|
||||
this.logger.debug(`${method} ${url} ${statusCode} ${duration}ms ${ip}`);
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
this.logger.verbose(JSON.stringify(req.body, replacer));
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPersonColor1738889177573 implements MigrationInterface {
|
||||
name = 'AddPersonColor1738889177573'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { UserAvatarColor } from 'src/enum';
|
||||
|
||||
registerEnumType(UserAvatarColor, {
|
||||
name: 'UserAvatarColor',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class User {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field(() => UserAvatarColor)
|
||||
avatarColor!: UserAvatarColor;
|
||||
|
||||
@Field()
|
||||
profileImagePath!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
profileChangedAt!: Date;
|
||||
}
|
||||
@@ -524,7 +524,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
.executeTakeFirst() as Promise<AssetEntity | undefined>;
|
||||
}
|
||||
|
||||
private getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
|
||||
getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
|
||||
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
|
||||
|
||||
return this.db
|
||||
|
||||
@@ -9,10 +9,13 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { IProcessRepository } from 'src/interfaces/process.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -70,10 +73,7 @@ export const repositories = [
|
||||
MetadataRepository,
|
||||
NotificationRepository,
|
||||
OAuthRepository,
|
||||
ProcessRepository,
|
||||
SessionRepository,
|
||||
ServerInfoRepository,
|
||||
SystemMetadataRepository,
|
||||
TelemetryRepository,
|
||||
TrashRepository,
|
||||
ViewRepository,
|
||||
@@ -92,10 +92,13 @@ export const providers = [
|
||||
{ provide: IMoveRepository, useClass: MoveRepository },
|
||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||
{ provide: IProcessRepository, useClass: ProcessRepository },
|
||||
{ provide: ISearchRepository, useClass: SearchRepository },
|
||||
{ provide: ISessionRepository, useClass: SessionRepository },
|
||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||
{ provide: IStackRepository, useClass: StackRepository },
|
||||
{ provide: IStorageRepository, useClass: StorageRepository },
|
||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
];
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { IConfigRepository } from 'src/types';
|
||||
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(LoggingRepository.name, () => {
|
||||
let sut: LoggingRepository;
|
||||
|
||||
let configMock: Mocked<ConfigRepository>;
|
||||
let configMock: Mocked<IConfigRepository>;
|
||||
let clsMock: Mocked<ClsService>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { getName } from 'i18n-iso-countries';
|
||||
import { Expression, Kysely, sql, SqlBool } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -11,9 +11,9 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
|
||||
import { LogLevel, SystemMetadataKey } from 'src/enum';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
|
||||
export interface MapMarkerSearchOptions {
|
||||
isArchived?: boolean;
|
||||
@@ -48,7 +48,7 @@ interface MapDB extends DB {
|
||||
export class MapRepository {
|
||||
constructor(
|
||||
private configRepository: ConfigRepository,
|
||||
private metadataRepository: SystemMetadataRepository,
|
||||
@Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,
|
||||
private logger: LoggingRepository,
|
||||
@InjectKysely() private db: Kysely<MapDB>,
|
||||
) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository';
|
||||
import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { ILoggingRepository } from 'src/types';
|
||||
import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(NotificationRepository.name, () => {
|
||||
let sut: NotificationRepository;
|
||||
let loggerMock: Mocked<LoggingRepository>;
|
||||
let loggerMock: Mocked<ILoggingRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked<LoggingRepository>;
|
||||
loggerMock = newLoggingRepositoryMock();
|
||||
|
||||
sut = new NotificationRepository(loggerMock as LoggingRepository);
|
||||
sut = new NotificationRepository(loggerMock as ILoggingRepository as LoggingRepository);
|
||||
});
|
||||
|
||||
describe('renderEmail', () => {
|
||||
|
||||
@@ -43,12 +43,7 @@ export class OAuthRepository {
|
||||
const params = client.callbackParams(url);
|
||||
try {
|
||||
const tokens = await client.callback(redirectUrl, params, { state: params.state });
|
||||
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||
if (!profile.sub) {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
||||
return profile;
|
||||
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||
} catch (error: Error | any) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { IProcessRepository } from 'src/interfaces/process.interface';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessRepository {
|
||||
export class ProcessRepository implements IProcessRepository {
|
||||
constructor(private logger: LoggingRepository) {
|
||||
this.logger.setContext(ProcessRepository.name);
|
||||
this.logger.setContext(StorageRepository.name);
|
||||
}
|
||||
|
||||
spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
|
||||
@@ -3,37 +3,36 @@ import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DB, Sessions } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { withUser } from 'src/entities/session.entity';
|
||||
import { SessionEntity, withUser } from 'src/entities/session.entity';
|
||||
import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
|
||||
export type SessionSearchOptions = { updatedBefore: Date };
|
||||
|
||||
@Injectable()
|
||||
export class SessionRepository {
|
||||
export class SessionRepository implements ISessionRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
|
||||
search(options: SessionSearchOptions) {
|
||||
search(options: SessionSearchOptions): Promise<SessionEntity[]> {
|
||||
return this.db
|
||||
.selectFrom('sessions')
|
||||
.selectAll()
|
||||
.where('sessions.updatedAt', '<=', options.updatedBefore)
|
||||
.execute();
|
||||
.execute() as Promise<SessionEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByToken(token: string) {
|
||||
getByToken(token: string): Promise<SessionEntity | undefined> {
|
||||
return this.db
|
||||
.selectFrom('sessions')
|
||||
.innerJoinLateral(withUser, (join) => join.onTrue())
|
||||
.selectAll('sessions')
|
||||
.select((eb) => eb.fn.toJson('user').as('user'))
|
||||
.where('sessions.token', '=', token)
|
||||
.executeTakeFirst();
|
||||
.executeTakeFirst() as Promise<SessionEntity | undefined>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getByUserId(userId: string) {
|
||||
getByUserId(userId: string): Promise<SessionEntity[]> {
|
||||
return this.db
|
||||
.selectFrom('sessions')
|
||||
.innerJoinLateral(withUser, (join) => join.onTrue())
|
||||
@@ -42,24 +41,30 @@ export class SessionRepository {
|
||||
.where('sessions.userId', '=', userId)
|
||||
.orderBy('sessions.updatedAt', 'desc')
|
||||
.orderBy('sessions.createdAt', 'desc')
|
||||
.execute();
|
||||
.execute() as unknown as Promise<SessionEntity[]>;
|
||||
}
|
||||
|
||||
create(dto: Insertable<Sessions>) {
|
||||
return this.db.insertInto('sessions').values(dto).returningAll().executeTakeFirstOrThrow();
|
||||
async create(dto: Insertable<Sessions>): Promise<SessionEntity> {
|
||||
const { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } = await this.db
|
||||
.insertInto('sessions')
|
||||
.values(dto)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { id, token, userId, createdAt, updatedAt, deviceType, deviceOS } as SessionEntity;
|
||||
}
|
||||
|
||||
update(id: string, dto: Updateable<Sessions>) {
|
||||
update(id: string, dto: Updateable<Sessions>): Promise<SessionEntity> {
|
||||
return this.db
|
||||
.updateTable('sessions')
|
||||
.set(dto)
|
||||
.where('sessions.id', '=', asUuid(id))
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
.executeTakeFirstOrThrow() as Promise<SessionEntity>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async delete(id: string) {
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DB, SharedLinks } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { ISharedLinkRepository, SharedLinkSearchOptions } from 'src/interfaces/shared-link.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SharedLinkRepository implements ISharedLinkRepository {
|
||||
@@ -93,7 +93,7 @@ export class SharedLinkRepository implements ISharedLinkRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> {
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]> {
|
||||
return this.db
|
||||
.selectFrom('shared_links')
|
||||
.selectAll('shared_links')
|
||||
@@ -149,7 +149,6 @@ export class SharedLinkRepository implements ISharedLinkRepository {
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('album').as('album'))
|
||||
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)]))
|
||||
.$if(!!albumId, (eb) => eb.where('shared_links.albumId', '=', albumId!))
|
||||
.orderBy('shared_links.createdAt', 'desc')
|
||||
.distinctOn(['shared_links.createdAt'])
|
||||
.execute() as unknown as Promise<SharedLinkEntity[]>;
|
||||
|
||||
@@ -2,8 +2,8 @@ import mockfs from 'mock-fs';
|
||||
import { CrawlOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
import { ILoggingRepository } from 'src/types';
|
||||
import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
@@ -182,11 +182,11 @@ const tests: Test[] = [
|
||||
|
||||
describe(StorageRepository.name, () => {
|
||||
let sut: StorageRepository;
|
||||
let logger: Mocked<ILoggingRepository>;
|
||||
let logger: ILoggingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = newLoggingRepositoryMock();
|
||||
sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository);
|
||||
sut = new StorageRepository(logger as LoggingRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { WatchOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { ErrnoException } from 'fast-glob/out/types';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -170,6 +171,19 @@ export class StorageRepository implements IStorageRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private errorHandler = (error: ErrnoException) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger.warn(`Path ${error.path} does not exist, ignoring.`);
|
||||
return true;
|
||||
} else if (error.code === 'EACCES') {
|
||||
this.logger.warn(`Permission denied for path ${error.path}, ignoring.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.error(`Error while walking path ${error.path}: ${error.message}`);
|
||||
return false;
|
||||
};
|
||||
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToCrawl.length === 0) {
|
||||
@@ -185,6 +199,7 @@ export class StorageRepository implements IStorageRepository {
|
||||
onlyFiles: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
errorHandler: this.errorHandler,
|
||||
});
|
||||
|
||||
let batch: string[] = [];
|
||||
|
||||
@@ -5,11 +5,12 @@ import { readFile } from 'node:fs/promises';
|
||||
import { DB, SystemMetadata as DbSystemMetadata } from 'src/db';
|
||||
import { GenerateSql } from 'src/decorators';
|
||||
import { SystemMetadata } from 'src/entities/system-metadata.entity';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
|
||||
type Upsert = Insertable<DbSystemMetadata>;
|
||||
|
||||
@Injectable()
|
||||
export class SystemMetadataRepository {
|
||||
export class SystemMetadataRepository implements ISystemMetadataRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: ['metadata_key'] })
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { UsersResolver } from 'src/resolvers/user.resolver';
|
||||
|
||||
export const resolvers = [UsersResolver];
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { User } from 'src/models/user.model';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
|
||||
@Resolver(() => User)
|
||||
export class UsersResolver {
|
||||
constructor(private service: UserService) {}
|
||||
|
||||
@Authenticated()
|
||||
@Query(() => User)
|
||||
async user(@Args('id', { type: () => Int }) id: string) {
|
||||
return this.service.get(id);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Query(() => [User])
|
||||
async users(@Auth() auth: AuthDto) {
|
||||
return this.service.search(auth);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# ------------------------------------------------------
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
"""
|
||||
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||
"""
|
||||
scalar DateTime
|
||||
|
||||
type Query {
|
||||
user(id: Int!): User!
|
||||
users: [User!]!
|
||||
}
|
||||
|
||||
type User {
|
||||
avatarColor: UserAvatarColor!
|
||||
email: String!
|
||||
id: String!
|
||||
name: String!
|
||||
profileChangedAt: DateTime
|
||||
profileImagePath: String!
|
||||
}
|
||||
|
||||
enum UserAvatarColor {
|
||||
AMBER
|
||||
BLUE
|
||||
GRAY
|
||||
GREEN
|
||||
ORANGE
|
||||
PINK
|
||||
PRIMARY
|
||||
PURPLE
|
||||
RED
|
||||
YELLOW
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ReactionType } from 'src/dtos/activity.dto';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import { IActivityRepository } from 'src/types';
|
||||
import { activityStub } from 'test/fixtures/activity.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(ActivityService.name, () => {
|
||||
let sut: ActivityService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let activityMock: Mocked<IActivityRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(ActivityService));
|
||||
({ sut, accessMock, activityMock } = newTestService(ActivityService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -19,12 +24,12 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get all', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: undefined,
|
||||
@@ -32,14 +37,14 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should filter by type=like', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: true,
|
||||
@@ -47,14 +52,14 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should filter by type=comment', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({
|
||||
expect(activityMock.search).toHaveBeenCalledWith({
|
||||
assetId: 'asset-id',
|
||||
albumId: 'album-id',
|
||||
isLiked: false,
|
||||
@@ -64,8 +69,8 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the comment count', async () => {
|
||||
mocks.activity.getStatistics.mockResolvedValue(1);
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId]));
|
||||
activityMock.getStatistics.mockResolvedValue(1);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId]));
|
||||
await expect(
|
||||
sut.getStatistics(authStub.admin, {
|
||||
assetId: 'asset-id',
|
||||
@@ -88,8 +93,8 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.create.mockResolvedValue(activityStub.oneComment);
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
@@ -98,7 +103,7 @@ describe(ActivityService.name, () => {
|
||||
comment: 'comment',
|
||||
});
|
||||
|
||||
expect(mocks.activity.create).toHaveBeenCalledWith({
|
||||
expect(activityMock.create).toHaveBeenCalledWith({
|
||||
userId: 'admin_id',
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
@@ -108,8 +113,8 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.create.mockResolvedValue(activityStub.oneComment);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
@@ -122,9 +127,9 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.create.mockResolvedValue(activityStub.liked);
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.create.mockResolvedValue(activityStub.liked);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
@@ -132,7 +137,7 @@ describe(ActivityService.name, () => {
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
|
||||
expect(mocks.activity.create).toHaveBeenCalledWith({
|
||||
expect(activityMock.create).toHaveBeenCalledWith({
|
||||
userId: 'admin_id',
|
||||
albumId: 'album-id',
|
||||
assetId: 'asset-id',
|
||||
@@ -141,9 +146,9 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.activity.search.mockResolvedValue([activityStub.liked]);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([activityStub.liked]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
@@ -151,26 +156,26 @@ describe(ActivityService.name, () => {
|
||||
type: ReactionType.LIKE,
|
||||
});
|
||||
|
||||
expect(mocks.activity.create).not.toHaveBeenCalled();
|
||||
expect(activityMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
||||
expect(activityMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the activity owner delete a comment', async () => {
|
||||
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
|
||||
it('should let the album owner delete a comment', async () => {
|
||||
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith('activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,29 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
import { IAlbumUserRepository } from 'src/types';
|
||||
import { albumStub } from 'test/fixtures/album.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(AlbumService.name, () => {
|
||||
let sut: AlbumService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let albumMock: Mocked<IAlbumRepository>;
|
||||
let albumUserMock: Mocked<IAlbumUserRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AlbumService));
|
||||
({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -22,25 +33,25 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the album count', async () => {
|
||||
mocks.album.getOwned.mockResolvedValue([]);
|
||||
mocks.album.getShared.mockResolvedValue([]);
|
||||
mocks.album.getNotShared.mockResolvedValue([]);
|
||||
albumMock.getOwned.mockResolvedValue([]);
|
||||
albumMock.getShared.mockResolvedValue([]);
|
||||
albumMock.getNotShared.mockResolvedValue([]);
|
||||
await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({
|
||||
owned: 0,
|
||||
shared: 0,
|
||||
notShared: 0,
|
||||
});
|
||||
|
||||
expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('gets list of albums for auth user', async () => {
|
||||
mocks.album.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
|
||||
]);
|
||||
@@ -52,8 +63,8 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('gets list of albums that have a specific asset', async () => {
|
||||
mocks.album.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
@@ -65,37 +76,37 @@ describe(AlbumService.name, () => {
|
||||
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.oneAsset.id);
|
||||
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.getByAssetId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets list of albums that are shared', async () => {
|
||||
mocks.album.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.sharedWithUser.id);
|
||||
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.getShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
mocks.album.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: false });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(albumStub.empty.id);
|
||||
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.getNotShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
mocks.album.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
@@ -108,14 +119,14 @@ describe(AlbumService.name, () => {
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assetCount).toEqual(1);
|
||||
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates album', async () => {
|
||||
mocks.album.create.mockResolvedValue(albumStub.empty);
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||
albumMock.create.mockResolvedValue(albumStub.empty);
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
@@ -124,7 +135,7 @@ describe(AlbumService.name, () => {
|
||||
assetIds: ['123'],
|
||||
});
|
||||
|
||||
expect(mocks.album.create).toHaveBeenCalledWith(
|
||||
expect(albumMock.create).toHaveBeenCalledWith(
|
||||
{
|
||||
ownerId: authStub.admin.user.id,
|
||||
albumName: albumStub.empty.albumName,
|
||||
@@ -136,30 +147,30 @@ describe(AlbumService.name, () => {
|
||||
[{ userId: 'user-id', role: AlbumUserRole.EDITOR }],
|
||||
);
|
||||
|
||||
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-id', {});
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('album.invite', {
|
||||
id: albumStub.empty.id,
|
||||
userId: 'user-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should require valid userIds', async () => {
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
userMock.get.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.user.get).toHaveBeenCalledWith('user-3', {});
|
||||
expect(mocks.album.create).not.toHaveBeenCalled();
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-3', {});
|
||||
expect(albumMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only add assets the user is allowed to access', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
mocks.album.create.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
albumMock.create.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Test album',
|
||||
@@ -167,7 +178,7 @@ describe(AlbumService.name, () => {
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
});
|
||||
|
||||
expect(mocks.album.create).toHaveBeenCalledWith(
|
||||
expect(albumMock.create).toHaveBeenCalledWith(
|
||||
{
|
||||
ownerId: authStub.admin.user.id,
|
||||
albumName: 'Test album',
|
||||
@@ -178,7 +189,7 @@ describe(AlbumService.name, () => {
|
||||
['asset-1'],
|
||||
[],
|
||||
);
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2']),
|
||||
);
|
||||
@@ -187,7 +198,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should prevent updating an album that does not exist', async () => {
|
||||
mocks.album.getById.mockResolvedValue(void 0);
|
||||
albumMock.getById.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.user1, 'invalid-id', {
|
||||
@@ -195,7 +206,7 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent updating a not owned album (shared with auth user)', async () => {
|
||||
@@ -207,10 +218,10 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.album.update.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
@@ -218,22 +229,22 @@ describe(AlbumService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.getAssetIds).toHaveBeenCalledWith('album-4', ['not-in-album']);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow the owner to update the album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.album.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
albumName: 'new album name',
|
||||
});
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-4', {
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-4', {
|
||||
id: 'album-4',
|
||||
albumName: 'new album name',
|
||||
});
|
||||
@@ -242,33 +253,33 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error for an album not found', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.album.delete).not.toHaveBeenCalled();
|
||||
expect(albumMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.album.delete).not.toHaveBeenCalled();
|
||||
expect(albumMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the owner delete an album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id]));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.empty);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await sut.delete(authStub.admin, albumStub.empty.id);
|
||||
|
||||
expect(mocks.album.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.delete).toHaveBeenCalledWith(albumStub.empty.id);
|
||||
expect(albumMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,47 +288,47 @@ describe(AlbumService.name, () => {
|
||||
await expect(
|
||||
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
|
||||
albumUsers: [{ userId: authStub.admin.user.id }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the userId is the ownerId', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
|
||||
albumUsers: [{ userId: userStub.user1.id }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
mocks.user.get.mockResolvedValue(userStub.user2);
|
||||
mocks.albumUser.create.mockResolvedValue({
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userStub.user2);
|
||||
albumUserMock.create.mockResolvedValue({
|
||||
usersId: userStub.user2.id,
|
||||
albumsId: albumStub.sharedWithAdmin.id,
|
||||
role: AlbumUserRole.EDITOR,
|
||||
@@ -325,11 +336,11 @@ describe(AlbumService.name, () => {
|
||||
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
|
||||
albumUsers: [{ userId: authStub.user2.user.id }],
|
||||
});
|
||||
expect(mocks.albumUser.create).toHaveBeenCalledWith({
|
||||
expect(albumUserMock.create).toHaveBeenCalledWith({
|
||||
usersId: authStub.user2.user.id,
|
||||
albumsId: albumStub.sharedWithAdmin.id,
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('album.invite', {
|
||||
id: albumStub.sharedWithAdmin.id,
|
||||
userId: userStub.user2.id,
|
||||
});
|
||||
@@ -338,94 +349,94 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
|
||||
mocks.album.getById.mockResolvedValue(void 0);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
|
||||
albumMock.getById.mockResolvedValue(void 0);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id]));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
|
||||
expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumUserMock.delete).toHaveBeenCalledWith({
|
||||
albumsId: albumStub.sharedWithUser.id,
|
||||
usersId: userStub.user1.id,
|
||||
});
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithMultiple);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.albumUser.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
expect(albumUserMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.user1.user.id,
|
||||
new Set([albumStub.sharedWithMultiple.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id);
|
||||
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
|
||||
expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumUserMock.delete).toHaveBeenCalledWith({
|
||||
albumsId: albumStub.sharedWithUser.id,
|
||||
usersId: authStub.user1.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
|
||||
expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
|
||||
expect(albumUserMock.delete).toHaveBeenCalledWith({
|
||||
albumsId: albumStub.sharedWithUser.id,
|
||||
usersId: authStub.user1.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.empty);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.user.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.empty);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user role', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, {
|
||||
role: AlbumUserRole.EDITOR,
|
||||
});
|
||||
expect(mocks.albumUser.update).toHaveBeenCalledWith(
|
||||
expect(albumUserMock.update).toHaveBeenCalledWith(
|
||||
{ albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id },
|
||||
{ role: AlbumUserRole.EDITOR },
|
||||
);
|
||||
@@ -434,9 +445,9 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('getAlbumInfo', () => {
|
||||
it('should get a shared album', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
@@ -447,17 +458,17 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([albumStub.oneAsset.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
@@ -468,17 +479,17 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(authStub.adminSharedLink, 'album-123', {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set(['album-123']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via shared with user', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
@@ -489,8 +500,8 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(authStub.user1, 'album-123', {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
authStub.user1.user.id,
|
||||
new Set(['album-123']),
|
||||
AlbumUserRole.VIEWER,
|
||||
@@ -500,8 +511,8 @@ describe(AlbumService.name, () => {
|
||||
it('should throw an error for no access', async () => {
|
||||
await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123']));
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-123']));
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['album-123']),
|
||||
AlbumUserRole.VIEWER,
|
||||
@@ -511,10 +522,10 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
@@ -524,37 +535,37 @@ describe(AlbumService.name, () => {
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-123', {
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-123', {
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalled();
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
@@ -564,34 +575,34 @@ describe(AlbumService.name, () => {
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-123', {
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
|
||||
id: 'album-123',
|
||||
recipientIds: ['admin_id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow a shared user with viewer access to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([]));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([]));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a shared link user to add assets', async () => {
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
@@ -601,115 +612,115 @@ describe(AlbumService.name, () => {
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-123', {
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set(['album-123']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledWith('album-123', {
|
||||
expect(albumMock.update).toHaveBeenCalledWith('album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should skip duplicate assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the album', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
mocks.album.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should allow the owner to remove assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']);
|
||||
expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']);
|
||||
});
|
||||
|
||||
it('should skip assets not in the album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
|
||||
albumMock.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow owner to remove all assets from the album', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
@@ -717,16 +728,16 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should reset the thumbnail if it is removed', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
|
||||
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(mocks.album.updateThumbnails).toHaveBeenCalled();
|
||||
expect(albumMock.updateThumbnails).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { APIKeyService } from 'src/services/api-key.service';
|
||||
import { IApiKeyRepository } from 'src/types';
|
||||
import { keyStub } from 'test/fixtures/api-key.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
let keyMock: Mocked<IApiKeyRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(APIKeyService));
|
||||
({ sut, cryptoMock, keyMock } = newTestService(APIKeyService));
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
mocks.apiKey.create.mockResolvedValue(keyStub.admin);
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] });
|
||||
expect(mocks.apiKey.create).toHaveBeenCalledWith({
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'Test Key',
|
||||
permissions: [Permission.ALL],
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(mocks.crypto.newPassword).toHaveBeenCalled();
|
||||
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
mocks.apiKey.create.mockResolvedValue(keyStub.admin);
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.create(authStub.admin, { permissions: [Permission.ALL] });
|
||||
|
||||
expect(mocks.apiKey.create).toHaveBeenCalledWith({
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'API Key',
|
||||
permissions: [Permission.ALL],
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(mocks.crypto.newPassword).toHaveBeenCalled();
|
||||
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||
@@ -55,16 +60,16 @@ describe(APIKeyService.name, () => {
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.apiKey.update).not.toHaveBeenCalledWith('random-guid');
|
||||
expect(keyMock.update).not.toHaveBeenCalledWith('random-guid');
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
mocks.apiKey.getById.mockResolvedValue(keyStub.admin);
|
||||
mocks.apiKey.update.mockResolvedValue(keyStub.admin);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
keyMock.update.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.update(authStub.admin, 'random-guid', { name: 'New Name' });
|
||||
|
||||
expect(mocks.apiKey.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' });
|
||||
expect(keyMock.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,15 +77,15 @@ describe(APIKeyService.name, () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.apiKey.delete).not.toHaveBeenCalledWith('random-guid');
|
||||
expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid');
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
mocks.apiKey.getById.mockResolvedValue(keyStub.admin);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.delete(authStub.admin, 'random-guid');
|
||||
|
||||
expect(mocks.apiKey.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
expect(keyMock.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,25 +93,25 @@ describe(APIKeyService.name, () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
mocks.apiKey.getById.mockResolvedValue(keyStub.admin);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.getById(authStub.admin, 'random-guid');
|
||||
|
||||
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
mocks.apiKey.getByUserId.mockResolvedValue([keyStub.admin]);
|
||||
keyMock.getByUserId.mockResolvedValue([keyStub.admin]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
|
||||
|
||||
expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN
|
||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum';
|
||||
import { JobName } from 'src/interfaces/job.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
@@ -18,7 +21,9 @@ import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
@@ -198,10 +203,15 @@ const copiedAsset = Object.freeze({
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AssetMediaService));
|
||||
({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService));
|
||||
});
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
@@ -211,25 +221,25 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,14 +308,14 @@ describe(AssetMediaService.name, () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/profile/admin_id',
|
||||
);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/upload/admin_id/ra/nd',
|
||||
);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,7 +330,7 @@ describe(AssetMediaService.name, () => {
|
||||
size: 42,
|
||||
};
|
||||
|
||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||
assetMock.create.mockResolvedValue(assetEntity);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -330,9 +340,9 @@ describe(AssetMediaService.name, () => {
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(storageMock.utimes).not.toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
@@ -349,16 +359,16 @@ describe(AssetMediaService.name, () => {
|
||||
size: 42,
|
||||
};
|
||||
|
||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||
assetMock.create.mockResolvedValue(assetEntity);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
|
||||
expect(mocks.asset.create).toHaveBeenCalled();
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
expect(assetMock.create).toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
@@ -377,19 +387,19 @@ describe(AssetMediaService.name, () => {
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.create.mockRejectedValue(error);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['fake_path/asset_1.jpeg', undefined] },
|
||||
});
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the duplicate could not be found by checksum', async () => {
|
||||
@@ -404,22 +414,22 @@ describe(AssetMediaService.name, () => {
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.create.mockRejectedValue(error);
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['fake_path/asset_1.jpeg', undefined] },
|
||||
});
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -432,13 +442,13 @@ describe(AssetMediaService.name, () => {
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide the linked motion asset', async () => {
|
||||
mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -451,25 +461,25 @@ describe(AssetMediaService.name, () => {
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
|
||||
});
|
||||
|
||||
it('should handle a sidecar file', async () => {
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
|
||||
mocks.asset.create.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
|
||||
status: AssetMediaStatus.CREATED,
|
||||
id: assetStub.image.id,
|
||||
});
|
||||
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
fileStub.photoSidecar.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -477,22 +487,22 @@ describe(AssetMediaService.name, () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true });
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true });
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -508,13 +518,13 @@ describe(AssetMediaService.name, () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
@@ -522,8 +532,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] });
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] });
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
@@ -531,8 +541,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the requested preview file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
@@ -551,8 +561,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should fall back to preview if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
@@ -579,8 +589,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should get preview file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).resolves.toEqual(
|
||||
@@ -594,8 +604,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should get thumbnail file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
@@ -613,27 +623,27 @@ describe(AssetMediaService.name, () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not a video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -645,8 +655,8 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should fall back to the original path', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -660,12 +670,12 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('checkExistingAssets', () => {
|
||||
it('should get existing asset ids', async () => {
|
||||
mocks.asset.getByDeviceIds.mockResolvedValue(['42']);
|
||||
assetMock.getByDeviceIds.mockResolvedValue(['42']);
|
||||
await expect(
|
||||
sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }),
|
||||
).resolves.toEqual({ existingIds: ['42'] });
|
||||
|
||||
expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
|
||||
expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -675,26 +685,26 @@ describe(AssetMediaService.name, () => {
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect(assetMock.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
sidecarPath: null,
|
||||
@@ -702,7 +712,7 @@ describe(AssetMediaService.name, () => {
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
||||
expect(assetMock.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sidecarPath: null,
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
@@ -710,12 +720,12 @@ describe(AssetMediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
@@ -726,13 +736,13 @@ describe(AssetMediaService.name, () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(
|
||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
||||
@@ -741,12 +751,12 @@ describe(AssetMediaService.name, () => {
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
@@ -757,25 +767,25 @@ describe(AssetMediaService.name, () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
@@ -787,27 +797,27 @@ describe(AssetMediaService.name, () => {
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.update.mockRejectedValue(error);
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
assetMock.update.mockRejectedValue(error);
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
id: sidecarAsset.id,
|
||||
});
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.updateAll).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
});
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -816,7 +826,7 @@ describe(AssetMediaService.name, () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
|
||||
|
||||
mocks.asset.getByChecksums.mockResolvedValue([
|
||||
assetMock.getByChecksums.mockResolvedValue([
|
||||
{ id: 'asset-1', checksum: file1 } as AssetEntity,
|
||||
{ id: 'asset-2', checksum: file2 } as AssetEntity,
|
||||
]);
|
||||
@@ -847,14 +857,14 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||
expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||
});
|
||||
|
||||
it('should return non-duplicates as well', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
|
||||
|
||||
mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]);
|
||||
assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]);
|
||||
|
||||
await expect(
|
||||
sut.bulkUploadCheck(authStub.admin, {
|
||||
@@ -879,7 +889,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||
expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -900,7 +910,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
await sut.onUploadError(request, file);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] },
|
||||
});
|
||||
|
||||
@@ -4,16 +4,22 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { AssetStats } from 'src/interfaces/asset.interface';
|
||||
import { JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { partnerStub } from 'test/fixtures/partner.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { vitest } from 'vitest';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
@@ -30,18 +36,27 @@ const statResponse: AssetStatsResponseDto = {
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let partnerMock: Mocked<IPartnerRepository>;
|
||||
let stackMock: Mocked<IStackRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
const mockGetById = (assets: AssetEntity[]) => {
|
||||
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
|
||||
assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AssetService));
|
||||
({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } =
|
||||
newTestService(AssetService));
|
||||
|
||||
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
|
||||
});
|
||||
@@ -62,8 +77,8 @@ describe(AssetService.name, () => {
|
||||
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
|
||||
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getByDayOfYear.mockResolvedValue([
|
||||
partnerMock.getAll.mockResolvedValue([]);
|
||||
assetMock.getByDayOfYear.mockResolvedValue([
|
||||
{
|
||||
yearsAgo: 1,
|
||||
assets: [image1, image2],
|
||||
@@ -84,16 +99,16 @@ describe(AssetService.name, () => {
|
||||
{ yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] },
|
||||
]);
|
||||
|
||||
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
|
||||
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
|
||||
});
|
||||
|
||||
it('should get memories with partners with inTimeline enabled', async () => {
|
||||
mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
|
||||
mocks.asset.getByDayOfYear.mockResolvedValue([]);
|
||||
partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
|
||||
assetMock.getByDayOfYear.mockResolvedValue([]);
|
||||
|
||||
await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
|
||||
|
||||
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([
|
||||
expect(assetMock.getByDayOfYear.mock.calls).toEqual([
|
||||
[[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }],
|
||||
]);
|
||||
});
|
||||
@@ -101,76 +116,76 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false });
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for archived assets', async () => {
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true });
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for favorite assets', async () => {
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true });
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for all assets', async () => {
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
|
||||
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {});
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRandom', () => {
|
||||
it('should get own random assets', async () => {
|
||||
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
||||
assetMock.getRandom.mockResolvedValue([assetStub.image]);
|
||||
await sut.getRandom(authStub.admin, 1);
|
||||
expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
|
||||
expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
|
||||
});
|
||||
|
||||
it('should not include partner assets if not in timeline', async () => {
|
||||
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
||||
mocks.partner.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]);
|
||||
assetMock.getRandom.mockResolvedValue([assetStub.image]);
|
||||
partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]);
|
||||
await sut.getRandom(authStub.admin, 1);
|
||||
expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
|
||||
expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
|
||||
});
|
||||
|
||||
it('should include partner assets if in timeline', async () => {
|
||||
mocks.asset.getRandom.mockResolvedValue([assetStub.image]);
|
||||
mocks.partner.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
|
||||
assetMock.getRandom.mockResolvedValue([assetStub.image]);
|
||||
partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
|
||||
await sut.getRandom(authStub.admin, 1);
|
||||
expect(mocks.asset.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1);
|
||||
expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should allow owner access', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
await sut.get(authStub.admin, assetStub.image.id);
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow shared link access', async () => {
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
await sut.get(authStub.adminSharedLink, assetStub.image.id);
|
||||
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip metadata for shared link if exif is disabled', async () => {
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
const result = await sut.get(
|
||||
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
||||
@@ -179,27 +194,27 @@ describe(AssetService.name, () => {
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ hasMetadata: false }));
|
||||
expect(result).not.toHaveProperty('exifInfo');
|
||||
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow partner sharing access', async () => {
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
await sut.get(authStub.admin, assetStub.image.id);
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow shared album access', async () => {
|
||||
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
await sut.get(authStub.admin, assetStub.image.id);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
);
|
||||
@@ -207,17 +222,17 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.asset.getById).not.toHaveBeenCalled();
|
||||
expect(assetMock.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for an invalid shared link', async () => {
|
||||
await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.getById).not.toHaveBeenCalled();
|
||||
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
|
||||
expect(assetMock.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the asset could not be found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -227,40 +242,40 @@ describe(AssetService.name, () => {
|
||||
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update the asset', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.update.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
|
||||
});
|
||||
|
||||
it('should update the exif description', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.update.mockResolvedValue(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
|
||||
});
|
||||
|
||||
it('should update the exif rating', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
|
||||
mocks.asset.update.mockResolvedValueOnce(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.update.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { rating: 3 });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part could not be found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
@@ -268,20 +283,20 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part is not a video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
@@ -289,20 +304,20 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail linking a live video if the motion part has a different owner', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
@@ -310,79 +325,79 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should link a live video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce({
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce({
|
||||
...assetStub.livePhotoMotionAsset,
|
||||
ownerId: authStub.admin.user.id,
|
||||
isVisible: true,
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
|
||||
mocks.asset.update.mockResolvedValue(assetStub.image);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.image);
|
||||
assetMock.update.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if asset could not be found after update', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should unlink a live video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
mocks.asset.update.mockResolvedValueOnce(assetStub.image);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.update.mockResolvedValueOnce(assetStub.image);
|
||||
|
||||
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: null,
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', {
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('asset.show', {
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
userId: userStub.admin.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail unlinking a live video if the asset could not be found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
mocks.asset.getById.mockResolvedValueOnce(undefined);
|
||||
assetMock.getById.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(eventMock.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -397,13 +412,13 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should update all assets', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||
});
|
||||
|
||||
it('should not update Assets table if no relevant fields are provided', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.updateAll(authStub.admin, {
|
||||
ids: ['asset-1'],
|
||||
latitude: 0,
|
||||
@@ -413,11 +428,11 @@ describe(AssetService.name, () => {
|
||||
duplicateId: undefined,
|
||||
rating: undefined,
|
||||
});
|
||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.updateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update Assets table if isArchived field is provided', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.updateAll(authStub.admin, {
|
||||
ids: ['asset-1'],
|
||||
latitude: 0,
|
||||
@@ -427,7 +442,7 @@ describe(AssetService.name, () => {
|
||||
duplicateId: undefined,
|
||||
rating: undefined,
|
||||
});
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
||||
expect(assetMock.updateAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -441,26 +456,26 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should force delete a batch of assets', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('assets.delete', {
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', {
|
||||
assetIds: ['asset1', 'asset2'],
|
||||
userId: 'user-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should soft delete a batch of assets', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(mocks.job.queue.mock.calls).toEqual([]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,27 +489,27 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
||||
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
||||
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
||||
systemMock.get.mockResolvedValue({ trash: { enabled: false } });
|
||||
|
||||
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS);
|
||||
|
||||
expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue assets for deletion after trash duration', async () => {
|
||||
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
||||
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
||||
systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
||||
|
||||
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS);
|
||||
|
||||
expect(mocks.asset.getAll).toHaveBeenCalledWith(expect.anything(), {
|
||||
expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), {
|
||||
trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(),
|
||||
});
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
|
||||
]);
|
||||
});
|
||||
@@ -504,11 +519,11 @@ describe(AssetService.name, () => {
|
||||
it('should remove faces', async () => {
|
||||
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
|
||||
|
||||
mocks.asset.getById.mockResolvedValue(assetWithFace);
|
||||
assetMock.getById.mockResolvedValue(assetWithFace);
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.job.queue.mock.calls).toEqual([
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.DELETE_FILES,
|
||||
@@ -525,41 +540,41 @@ describe(AssetService.name, () => {
|
||||
],
|
||||
]);
|
||||
|
||||
expect(mocks.asset.remove).toHaveBeenCalledWith(assetWithFace);
|
||||
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
|
||||
});
|
||||
|
||||
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||
assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.stack.update).toHaveBeenCalledWith('stack-1', {
|
||||
expect(stackMock.update).toHaveBeenCalledWith('stack-1', {
|
||||
id: 'stack-1',
|
||||
primaryAssetId: 'stack-child-asset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
assetMock.getById.mockResolvedValue({
|
||||
...assetStub.primaryImage,
|
||||
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
|
||||
} as AssetEntity);
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-1');
|
||||
expect(stackMock.delete).toHaveBeenCalledWith('stack-1');
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
|
||||
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
assetMock.getLivePhotoCount.mockResolvedValue(0);
|
||||
|
||||
await sut.handleAssetDeletion({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
deleteOnDisk: true,
|
||||
});
|
||||
|
||||
expect(mocks.job.queue.mock.calls).toEqual([
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.ASSET_DELETION,
|
||||
@@ -581,15 +596,15 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should not delete a live motion part if it is being used by another asset', async () => {
|
||||
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
assetMock.getLivePhotoCount.mockResolvedValue(2);
|
||||
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
|
||||
await sut.handleAssetDeletion({
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
deleteOnDisk: true,
|
||||
});
|
||||
|
||||
expect(mocks.job.queue.mock.calls).toEqual([
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.DELETE_FILES,
|
||||
@@ -602,9 +617,9 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should update usage', async () => {
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
|
||||
});
|
||||
|
||||
it('should fail if asset could not be found', async () => {
|
||||
@@ -616,27 +631,27 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('run', () => {
|
||||
it('should run the refresh faces job', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should run the refresh metadata job', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should run the refresh thumbnails job', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should run the transcode video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -644,7 +659,7 @@ describe(AssetService.name, () => {
|
||||
it('get assets by device id', async () => {
|
||||
const assets = [assetStub.image, assetStub.image1];
|
||||
|
||||
mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
|
||||
assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
|
||||
|
||||
const deviceId = 'device-id';
|
||||
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FileReportItemDto } from 'src/dtos/audit.dto';
|
||||
import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { IAuditRepository } from 'src/types';
|
||||
import { auditStub } from 'test/fixtures/audit.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(AuditService.name, () => {
|
||||
let sut: AuditService;
|
||||
let mocks: ServiceMocks;
|
||||
let auditMock: Mocked<IAuditRepository>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
let personMock: Mocked<IPersonRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AuditService));
|
||||
({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -22,13 +32,13 @@ describe(AuditService.name, () => {
|
||||
describe('handleCleanup', () => {
|
||||
it('should delete old audit entries', async () => {
|
||||
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
|
||||
expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeletes', () => {
|
||||
it('should require full sync if the request is older than 100 days', async () => {
|
||||
mocks.audit.getAfter.mockResolvedValue([]);
|
||||
auditMock.getAfter.mockResolvedValue([]);
|
||||
|
||||
const date = new Date(2022, 0, 1);
|
||||
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
|
||||
@@ -36,7 +46,7 @@ describe(AuditService.name, () => {
|
||||
ids: [],
|
||||
});
|
||||
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, {
|
||||
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
|
||||
action: DatabaseAction.DELETE,
|
||||
userIds: [authStub.admin.user.id],
|
||||
entityType: EntityType.ASSET,
|
||||
@@ -44,7 +54,7 @@ describe(AuditService.name, () => {
|
||||
});
|
||||
|
||||
it('should get any new or updated assets and deleted ids', async () => {
|
||||
mocks.audit.getAfter.mockResolvedValue([auditStub.delete.entityId]);
|
||||
auditMock.getAfter.mockResolvedValue([auditStub.delete.entityId]);
|
||||
|
||||
const date = new Date();
|
||||
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
|
||||
@@ -52,7 +62,7 @@ describe(AuditService.name, () => {
|
||||
ids: ['asset-deleted'],
|
||||
});
|
||||
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledWith(date, {
|
||||
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
|
||||
action: DatabaseAction.DELETE,
|
||||
userIds: [authStub.admin.user.id],
|
||||
entityType: EntityType.ASSET,
|
||||
@@ -64,7 +74,7 @@ describe(AuditService.name, () => {
|
||||
it('should fail if the file is not in the immich path', async () => {
|
||||
await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.crypto.hashFile).not.toHaveBeenCalled();
|
||||
expect(cryptoMock.hashFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get checksum for valid file', async () => {
|
||||
@@ -72,7 +82,7 @@ describe(AuditService.name, () => {
|
||||
{ filename: './upload/my-file.jpg', checksum: expect.any(String) },
|
||||
]);
|
||||
|
||||
expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
|
||||
expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,10 +94,10 @@ describe(AuditService.name, () => {
|
||||
]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update encoded video path', async () => {
|
||||
@@ -99,10 +109,10 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update preview path', async () => {
|
||||
@@ -114,14 +124,14 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: 'my-id',
|
||||
type: AssetFileType.PREVIEW,
|
||||
path: './upload/my-preview.png',
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update thumbnail path', async () => {
|
||||
@@ -133,14 +143,14 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: 'my-id',
|
||||
type: AssetFileType.THUMBNAIL,
|
||||
path: './upload/my-thumbnail.webp',
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update original path', async () => {
|
||||
@@ -152,10 +162,10 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update sidecar path', async () => {
|
||||
@@ -167,10 +177,10 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update face path', async () => {
|
||||
@@ -182,10 +192,10 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update profile path', async () => {
|
||||
@@ -197,10 +207,10 @@ describe(AuditService.name, () => {
|
||||
} as FileReportItemDto,
|
||||
]);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
expect(assetMock.upsertFile).not.toHaveBeenCalled();
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,22 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AuthType, Permission } from 'src/enum';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { IApiKeyRepository, IOAuthRepository } from 'src/types';
|
||||
import { keyStub } from 'test/fixtures/api-key.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { sessionStub } from 'test/fixtures/session.stub';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const oauthResponse = {
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
@@ -50,14 +58,23 @@ const oauthUserWithDefaultQuota = {
|
||||
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let keyMock: Mocked<IApiKeyRepository>;
|
||||
let oauthMock: Mocked<IOAuthRepository>;
|
||||
let sessionMock: Mocked<ISessionRepository>;
|
||||
let sharedLinkMock: Mocked<ISharedLinkRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AuthService));
|
||||
({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } =
|
||||
newTestService(AuthService));
|
||||
|
||||
mocks.oauth.authorize.mockResolvedValue('access-token');
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
|
||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
oauthMock.authorize.mockResolvedValue('access-token');
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email });
|
||||
oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -67,31 +84,31 @@ describe('AuthService', () => {
|
||||
describe('onBootstrap', () => {
|
||||
it('should init the repo', () => {
|
||||
sut.onBootstrap();
|
||||
expect(mocks.oauth.init).toHaveBeenCalled();
|
||||
expect(oauthMock.init).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
|
||||
mocks.session.create.mockResolvedValue(sessionStub.valid);
|
||||
userMock.getByEmail.mockResolvedValue(userStub.user1);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'user-id',
|
||||
@@ -101,7 +118,7 @@ describe('AuthService', () => {
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,23 +127,23 @@ describe('AuthService', () => {
|
||||
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.changePassword(auth, dto);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
|
||||
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
|
||||
expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
});
|
||||
|
||||
it('should throw when auth user email is not found', async () => {
|
||||
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
@@ -135,9 +152,9 @@ describe('AuthService', () => {
|
||||
const auth = { user: { email: 'test@imimch.com' } as UserEntity };
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||
cryptoMock.compareBcrypt.mockReturnValue(false);
|
||||
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: 'hash-password',
|
||||
} as UserEntity);
|
||||
@@ -149,7 +166,7 @@ describe('AuthService', () => {
|
||||
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getByEmail.mockResolvedValue({
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: '',
|
||||
} as UserEntity);
|
||||
@@ -160,7 +177,7 @@ describe('AuthService', () => {
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
const auth = { user: { id: '123' } } as AuthDto;
|
||||
await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({
|
||||
successful: true,
|
||||
@@ -185,8 +202,8 @@ describe('AuthService', () => {
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
|
||||
expect(mocks.session.delete).toHaveBeenCalledWith('token123');
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
|
||||
});
|
||||
|
||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||
@@ -203,14 +220,14 @@ describe('AuthService', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
||||
|
||||
it('should only allow one admin', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
userMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.user.getAdmin).toHaveBeenCalled();
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sign up the admin', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue({
|
||||
userMock.getAdmin.mockResolvedValue(void 0);
|
||||
userMock.create.mockResolvedValue({
|
||||
...dto,
|
||||
id: 'admin',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
@@ -223,8 +240,8 @@ describe('AuthService', () => {
|
||||
email: 'test@immich.com',
|
||||
name: 'immich admin',
|
||||
});
|
||||
expect(mocks.user.getAdmin).toHaveBeenCalled();
|
||||
expect(mocks.user.create).toHaveBeenCalled();
|
||||
expect(userMock.getAdmin).toHaveBeenCalled();
|
||||
expect(userMock.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,8 +257,8 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { authorization: 'Bearer auth_token' },
|
||||
@@ -267,7 +284,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': 'key' },
|
||||
@@ -278,7 +295,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should not accept a key on a non-shared route', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': 'key' },
|
||||
@@ -289,8 +306,8 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': 'key' },
|
||||
@@ -301,8 +318,8 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should accept a base64url key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userStub.admin);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') },
|
||||
@@ -313,12 +330,12 @@ describe('AuthService', () => {
|
||||
user: userStub.admin,
|
||||
sharedLink: sharedLinkStub.valid,
|
||||
});
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userStub.admin);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') },
|
||||
@@ -329,13 +346,13 @@ describe('AuthService', () => {
|
||||
user: userStub.admin,
|
||||
sharedLink: sharedLinkStub.valid,
|
||||
});
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
mocks.session.getByToken.mockResolvedValue(void 0);
|
||||
sessionMock.getByToken.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-user-token': 'auth_token' },
|
||||
@@ -346,7 +363,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { cookie: 'immich_access_token=auth_token' },
|
||||
@@ -360,7 +377,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should throw if admin route and not an admin', async () => {
|
||||
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { cookie: 'immich_access_token=auth_token' },
|
||||
@@ -371,8 +388,8 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any);
|
||||
mocks.session.update.mockResolvedValue(sessionStub.valid);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
|
||||
sessionMock.update.mockResolvedValue(sessionStub.valid);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { cookie: 'immich_access_token=auth_token' },
|
||||
@@ -380,13 +397,13 @@ describe('AuthService', () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
|
||||
expect(sessionMock.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - api key', () => {
|
||||
it('should throw an error if no api key is found', async () => {
|
||||
mocks.apiKey.getKey.mockResolvedValue(void 0);
|
||||
keyMock.getKey.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
@@ -394,11 +411,11 @@ describe('AuthService', () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
|
||||
it('should throw an error if api key has insufficient permissions', async () => {
|
||||
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey);
|
||||
keyMock.getKey.mockResolvedValue(keyStub.authKey);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
@@ -409,7 +426,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey);
|
||||
keyMock.getKey.mockResolvedValue(keyStub.authKey);
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
@@ -417,7 +434,7 @@ describe('AuthService', () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey });
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,14 +452,14 @@ describe('AuthService', () => {
|
||||
|
||||
describe('authorize', () => {
|
||||
it('should fail if oauth is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ oauth: { enabled: false } });
|
||||
systemMock.get.mockResolvedValue({ oauth: { enabled: false } });
|
||||
await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authorize the user', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
await sut.authorize({ redirectUri: 'https://demo.immich.app' });
|
||||
});
|
||||
});
|
||||
@@ -453,71 +470,71 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(userStub.user1);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
mocks.session.create.mockResolvedValue(sessionStub.valid);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
userMock.getByEmail.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub });
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' });
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' });
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
expect(userMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.session.create.mockResolvedValue(sessionStub.valid);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(userMock.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.session.create.mockResolvedValue(sessionStub.valid);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).not.toHaveBeenCalled();
|
||||
expect(mocks.user.create).not.toHaveBeenCalled();
|
||||
expect(userMock.getByEmail).not.toHaveBeenCalled();
|
||||
expect(userMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
@@ -529,68 +546,68 @@ describe('AuthService', () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
|
||||
mocks.session.create.mockResolvedValue(sessionStub.valid);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await sut.callback({ url }, loginDetails);
|
||||
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect');
|
||||
expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect');
|
||||
});
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 });
|
||||
});
|
||||
|
||||
it('should not set quota for 0 quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
expect(userMock.create).toHaveBeenCalledWith({
|
||||
email,
|
||||
name: ' ',
|
||||
oauthId: sub,
|
||||
@@ -600,17 +617,17 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.user1);
|
||||
mocks.user.create.mockResolvedValue(userStub.user1);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
userMock.getByEmail.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
oauthResponse,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
expect(userMock.create).toHaveBeenCalledWith({
|
||||
email,
|
||||
name: ' ',
|
||||
oauthId: sub,
|
||||
@@ -622,34 +639,34 @@ describe('AuthService', () => {
|
||||
|
||||
describe('link', () => {
|
||||
it('should link an account', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub });
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
|
||||
|
||||
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(userMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(userStub.user1);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
|
||||
await sut.unlink(authStub.user1);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' });
|
||||
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
@@ -339,7 +338,7 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
return { user: session.user as unknown as UserEntity, session: session as unknown as SessionEntity };
|
||||
return { user: session.user, session };
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid user token');
|
||||
|
||||
@@ -2,18 +2,29 @@ import { PassThrough } from 'node:stream';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { ImmichWorker, StorageFolder } from 'src/enum';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IProcessRepository } from 'src/interfaces/process.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { BackupService } from 'src/services/backup.service';
|
||||
import { IConfigRepository, ICronRepository } from 'src/types';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
import { mockSpawn, newTestService } from 'test/utils';
|
||||
import { describe, Mocked } from 'vitest';
|
||||
|
||||
describe(BackupService.name, () => {
|
||||
let sut: BackupService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let databaseMock: Mocked<IDatabaseRepository>;
|
||||
let configMock: Mocked<IConfigRepository>;
|
||||
let cronMock: Mocked<ICronRepository>;
|
||||
let processMock: Mocked<IProcessRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(BackupService));
|
||||
({ sut, cronMock, configMock, databaseMock, processMock, storageMock, systemMock } = newTestService(BackupService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -22,32 +33,32 @@ describe(BackupService.name, () => {
|
||||
|
||||
describe('onBootstrapEvent', () => {
|
||||
it('should init cron job and handle config changes', async () => {
|
||||
mocks.database.tryLock.mockResolvedValue(true);
|
||||
databaseMock.tryLock.mockResolvedValue(true);
|
||||
|
||||
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
|
||||
|
||||
expect(mocks.cron.create).toHaveBeenCalled();
|
||||
expect(cronMock.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize backup database cron job when lock is taken', async () => {
|
||||
mocks.database.tryLock.mockResolvedValue(false);
|
||||
databaseMock.tryLock.mockResolvedValue(false);
|
||||
|
||||
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
|
||||
|
||||
expect(mocks.cron.create).not.toHaveBeenCalled();
|
||||
expect(cronMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialise backup database job when running on microservices', async () => {
|
||||
mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES);
|
||||
configMock.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES);
|
||||
await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig });
|
||||
|
||||
expect(mocks.cron.create).not.toHaveBeenCalled();
|
||||
expect(cronMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigUpdateEvent', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.database.tryLock.mockResolvedValue(true);
|
||||
databaseMock.tryLock.mockResolvedValue(true);
|
||||
await sut.onConfigInit({ newConfig: defaults });
|
||||
});
|
||||
|
||||
@@ -64,66 +75,66 @@ describe(BackupService.name, () => {
|
||||
} as SystemConfig,
|
||||
});
|
||||
|
||||
expect(mocks.cron.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true });
|
||||
expect(mocks.cron.update).toHaveBeenCalled();
|
||||
expect(cronMock.update).toHaveBeenCalledWith({ name: 'backupDatabase', expression: '0 1 * * *', start: true });
|
||||
expect(cronMock.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if instance does not have the backup database lock', async () => {
|
||||
mocks.database.tryLock.mockResolvedValue(false);
|
||||
databaseMock.tryLock.mockResolvedValue(false);
|
||||
await sut.onConfigInit({ newConfig: defaults });
|
||||
sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults });
|
||||
expect(mocks.cron.update).not.toHaveBeenCalled();
|
||||
expect(cronMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupDatabaseBackups', () => {
|
||||
it('should do nothing if not reached keepLastAmount', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']);
|
||||
await sut.cleanupDatabaseBackups();
|
||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||
expect(storageMock.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove failed backup files', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
mocks.storage.readdir.mockResolvedValue([
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
storageMock.readdir.mockResolvedValue([
|
||||
'immich-db-backup-123.sql.gz.tmp',
|
||||
'immich-db-backup-234.sql.gz',
|
||||
'immich-db-backup-345.sql.gz.tmp',
|
||||
]);
|
||||
await sut.cleanupDatabaseBackups();
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`,
|
||||
);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove old backup files over keepLastAmount', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
mocks.storage.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']);
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']);
|
||||
await sut.cleanupDatabaseBackups();
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
expect(storageMock.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove old backup files over keepLastAmount and failed backups', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
mocks.storage.readdir.mockResolvedValue([
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
storageMock.readdir.mockResolvedValue([
|
||||
'immich-db-backup-1.sql.gz.tmp',
|
||||
'immich-db-backup-2.sql.gz',
|
||||
'immich-db-backup-3.sql.gz',
|
||||
]);
|
||||
await sut.cleanupDatabaseBackups();
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`,
|
||||
);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(
|
||||
`${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`,
|
||||
);
|
||||
});
|
||||
@@ -131,57 +142,57 @@ describe(BackupService.name, () => {
|
||||
|
||||
describe('handleBackupDatabase', () => {
|
||||
beforeEach(() => {
|
||||
mocks.storage.readdir.mockResolvedValue([]);
|
||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||
mocks.storage.rename.mockResolvedValue();
|
||||
mocks.storage.unlink.mockResolvedValue();
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
|
||||
storageMock.readdir.mockResolvedValue([]);
|
||||
processMock.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||
storageMock.rename.mockResolvedValue();
|
||||
storageMock.unlink.mockResolvedValue();
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||
storageMock.createWriteStream.mockReturnValue(new PassThrough());
|
||||
});
|
||||
it('should run a database backup successfully', async () => {
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.storage.createWriteStream).toHaveBeenCalled();
|
||||
expect(storageMock.createWriteStream).toHaveBeenCalled();
|
||||
});
|
||||
it('should rename file on success', async () => {
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.storage.rename).toHaveBeenCalled();
|
||||
expect(storageMock.rename).toHaveBeenCalled();
|
||||
});
|
||||
it('should fail if pg_dumpall fails', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
it('should not rename file if pgdump fails and gzip succeeds', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
expect(mocks.storage.rename).not.toHaveBeenCalled();
|
||||
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should fail if gzip fails', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
|
||||
processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
it('should fail if write stream fails', async () => {
|
||||
mocks.storage.createWriteStream.mockImplementation(() => {
|
||||
storageMock.createWriteStream.mockImplementation(() => {
|
||||
throw new Error('error');
|
||||
});
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
it('should fail if rename fails', async () => {
|
||||
mocks.storage.rename.mockRejectedValue(new Error('error'));
|
||||
storageMock.rename.mockRejectedValue(new Error('error'));
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
it('should ignore unlink failing and still return failed job status', async () => {
|
||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
mocks.storage.unlink.mockRejectedValue(new Error('error'));
|
||||
processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
||||
storageMock.unlink.mockRejectedValue(new Error('error'));
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(mocks.storage.unlink).toHaveBeenCalled();
|
||||
expect(storageMock.unlink).toHaveBeenCalled();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
it.each`
|
||||
@@ -195,9 +206,9 @@ describe(BackupService.name, () => {
|
||||
`(
|
||||
`should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
|
||||
async ({ postgresVersion, expectedVersion }) => {
|
||||
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||
databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||
await sut.handleBackupDatabase();
|
||||
expect(mocks.process.spawn).toHaveBeenCalledWith(
|
||||
expect(processMock.spawn).toHaveBeenCalledWith(
|
||||
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
@@ -209,9 +220,9 @@ describe(BackupService.name, () => {
|
||||
${'13.99.99'}
|
||||
${'18.0.0'}
|
||||
`(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => {
|
||||
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||
databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||
const result = await sut.handleBackupDatabase();
|
||||
expect(mocks.process.spawn).not.toHaveBeenCalled();
|
||||
expect(processMock.spawn).not.toHaveBeenCalled();
|
||||
expect(result).toBe(JobStatus.FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,10 +17,13 @@ import { IMachineLearningRepository } from 'src/interfaces/machine-learning.inte
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { IProcessRepository } from 'src/interfaces/process.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -30,7 +33,6 @@ import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MapRepository } from 'src/repositories/map.repository';
|
||||
import { MediaRepository } from 'src/repositories/media.repository';
|
||||
@@ -38,10 +40,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { NotificationRepository } from 'src/repositories/notification.repository';
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
import { SessionRepository } from 'src/repositories/session.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -62,7 +61,7 @@ export class BaseService {
|
||||
@Inject(IAssetRepository) protected assetRepository: IAssetRepository,
|
||||
protected configRepository: ConfigRepository,
|
||||
protected cronRepository: CronRepository,
|
||||
@Inject(ICryptoRepository) protected cryptoRepository: CryptoRepository,
|
||||
@Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
|
||||
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
|
||||
@Inject(IEventRepository) protected eventRepository: IEventRepository,
|
||||
@Inject(IJobRepository) protected jobRepository: IJobRepository,
|
||||
@@ -78,14 +77,14 @@ export class BaseService {
|
||||
protected oauthRepository: OAuthRepository,
|
||||
@Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository,
|
||||
@Inject(IPersonRepository) protected personRepository: IPersonRepository,
|
||||
protected processRepository: ProcessRepository,
|
||||
@Inject(IProcessRepository) protected processRepository: IProcessRepository,
|
||||
@Inject(ISearchRepository) protected searchRepository: ISearchRepository,
|
||||
protected serverInfoRepository: ServerInfoRepository,
|
||||
protected sessionRepository: SessionRepository,
|
||||
@Inject(ISessionRepository) protected sessionRepository: ISessionRepository,
|
||||
@Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IStackRepository) protected stackRepository: IStackRepository,
|
||||
@Inject(IStorageRepository) protected storageRepository: IStorageRepository,
|
||||
protected systemMetadataRepository: SystemMetadataRepository,
|
||||
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ITagRepository) protected tagRepository: ITagRepository,
|
||||
protected telemetryRepository: TelemetryRepository,
|
||||
protected trashRepository: TrashRepository,
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe, it } from 'vitest';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked, describe, it } from 'vitest';
|
||||
|
||||
describe(CliService.name, () => {
|
||||
let sut: CliService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(CliService));
|
||||
({ sut, userMock, systemMock } = newTestService(CliService));
|
||||
});
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('should list users', async () => {
|
||||
mocks.user.getList.mockResolvedValue([userStub.admin]);
|
||||
userMock.getList.mockResolvedValue([userStub.admin]);
|
||||
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetAdminPassword', () => {
|
||||
it('should only work when there is an admin account', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(void 0);
|
||||
userMock.getAdmin.mockResolvedValue(void 0);
|
||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
||||
|
||||
await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist');
|
||||
@@ -30,12 +34,12 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to a random password', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.admin);
|
||||
const ask = vitest.fn().mockImplementation(() => {});
|
||||
|
||||
const response = await sut.resetAdminPassword(ask);
|
||||
|
||||
const [id, update] = mocks.user.update.mock.calls[0];
|
||||
const [id, update] = userMock.update.mock.calls[0];
|
||||
|
||||
expect(response.provided).toBe(false);
|
||||
expect(ask).toHaveBeenCalled();
|
||||
@@ -44,12 +48,12 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should use the supplied password', async () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(userStub.admin);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.admin);
|
||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
||||
|
||||
const response = await sut.resetAdminPassword(ask);
|
||||
|
||||
const [id, update] = mocks.user.update.mock.calls[0];
|
||||
const [id, update] = userMock.update.mock.calls[0];
|
||||
|
||||
expect(response.provided).toBe(true);
|
||||
expect(ask).toHaveBeenCalled();
|
||||
@@ -61,28 +65,28 @@ describe(CliService.name, () => {
|
||||
describe('disablePasswordLogin', () => {
|
||||
it('should disable password login', async () => {
|
||||
await sut.disablePasswordLogin();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } });
|
||||
expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('enablePasswordLogin', () => {
|
||||
it('should enable password login', async () => {
|
||||
await sut.enablePasswordLogin();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {});
|
||||
expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableOAuthLogin', () => {
|
||||
it('should disable oauth login', async () => {
|
||||
await sut.disableOAuthLogin();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', {});
|
||||
expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableOAuthLogin', () => {
|
||||
it('should enable oauth login', async () => {
|
||||
await sut.enableOAuthLogin();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } });
|
||||
expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { DatabaseExtension, EXTENSION_NAMES, VectorExtension } from 'src/interfaces/database.interface';
|
||||
import {
|
||||
DatabaseExtension,
|
||||
EXTENSION_NAMES,
|
||||
IDatabaseRepository,
|
||||
VectorExtension,
|
||||
} from 'src/interfaces/database.interface';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
import { IConfigRepository, ILoggingRepository } from 'src/types';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(DatabaseService.name, () => {
|
||||
let sut: DatabaseService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
let configMock: Mocked<IConfigRepository>;
|
||||
let databaseMock: Mocked<IDatabaseRepository>;
|
||||
let loggerMock: Mocked<ILoggingRepository>;
|
||||
let extensionRange: string;
|
||||
let versionBelowRange: string;
|
||||
let minVersionInRange: string;
|
||||
@@ -14,16 +23,16 @@ describe(DatabaseService.name, () => {
|
||||
let versionAboveRange: string;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(DatabaseService));
|
||||
({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService));
|
||||
|
||||
extensionRange = '0.2.x';
|
||||
mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange);
|
||||
databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange);
|
||||
|
||||
versionBelowRange = '0.1.0';
|
||||
minVersionInRange = '0.2.0';
|
||||
updateInRange = '0.2.1';
|
||||
versionAboveRange = '0.3.0';
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: minVersionInRange,
|
||||
availableVersion: minVersionInRange,
|
||||
});
|
||||
@@ -35,11 +44,11 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
describe('onBootstrap', () => {
|
||||
it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
|
||||
mocks.database.getPostgresVersion.mockResolvedValueOnce('13.10.0');
|
||||
databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0');
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
|
||||
|
||||
expect(mocks.database.getPostgresVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
|
||||
@@ -47,7 +56,7 @@ describe(DatabaseService.name, () => {
|
||||
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
||||
])('should work with $extensionName', ({ extension, extensionName }) => {
|
||||
beforeEach(() => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
configMock.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
@@ -76,34 +85,34 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
|
||||
it(`should start up successfully with ${extension}`, async () => {
|
||||
mocks.database.getPostgresVersion.mockResolvedValue('14.0.0');
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getPostgresVersion.mockResolvedValue('14.0.0');
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: null,
|
||||
availableVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.getPostgresVersion).toHaveBeenCalled();
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledWith(extension);
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.getExtensionVersion).toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw an error if the ${extension} extension is not installed`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
|
||||
const message = `The ${extensionName} extension is not available in this Postgres instance.
|
||||
If using a container image, ensure the image has the extension installed.`;
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(message);
|
||||
|
||||
expect(mocks.database.createExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: versionBelowRange,
|
||||
availableVersion: versionBelowRange,
|
||||
});
|
||||
@@ -112,80 +121,80 @@ describe(DatabaseService.name, () => {
|
||||
`The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`,
|
||||
);
|
||||
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw an error if ${extension} extension version is a nightly`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(
|
||||
`The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`,
|
||||
);
|
||||
|
||||
expect(mocks.database.createExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should do in-range update for ${extension} extension`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: updateInRange,
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(mocks.database.updateVectorExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.getExtensionVersion).toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should not upgrade ${extension} if same version`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: minVersionInRange,
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw error if ${extension} available version is below range`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: versionBelowRange,
|
||||
installedVersion: null,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow();
|
||||
|
||||
expect(mocks.database.createExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw error if ${extension} available version is above range`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: versionAboveRange,
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow();
|
||||
|
||||
expect(mocks.database.createExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if available version is below installed version', async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: minVersionInRange,
|
||||
installedVersion: updateInRange,
|
||||
});
|
||||
@@ -194,13 +203,13 @@ describe(DatabaseService.name, () => {
|
||||
`The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`,
|
||||
);
|
||||
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if installed version is not in version range', async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: minVersionInRange,
|
||||
installedVersion: versionAboveRange,
|
||||
});
|
||||
@@ -209,84 +218,84 @@ describe(DatabaseService.name, () => {
|
||||
`The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`,
|
||||
);
|
||||
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should raise error if ${extension} extension upgrade failed`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: updateInRange,
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
mocks.database.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
|
||||
databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
|
||||
|
||||
expect(mocks.logger.warn.mock.calls[0][0]).toContain(
|
||||
expect(loggerMock.warn.mock.calls[0][0]).toContain(
|
||||
`The ${extensionName} extension can be updated to ${updateInRange}.`,
|
||||
);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should warn if ${extension} extension update requires restart`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
availableVersion: updateInRange,
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true });
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true });
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName);
|
||||
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should reindex ${extension} indices if needed`, async () => {
|
||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
||||
databaseMock.shouldReindex.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw an error if reindexing fails`, async () => {
|
||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
||||
mocks.database.reindex.mockRejectedValue(new Error('Error reindexing'));
|
||||
databaseMock.shouldReindex.mockResolvedValue(true);
|
||||
databaseMock.reindex.mockRejectedValue(new Error('Error reindexing'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toBeDefined();
|
||||
|
||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
||||
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.reindex).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not run vector reindexing checks.'),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should not reindex ${extension} indices if not needed`, async () => {
|
||||
mocks.database.shouldReindex.mockResolvedValue(false);
|
||||
databaseMock.shouldReindex.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
configMock.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
@@ -315,11 +324,11 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw error if pgvector extension could not be created`, async () => {
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
configMock.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
@@ -345,41 +354,41 @@ describe(DatabaseService.name, () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: null,
|
||||
availableVersion: minVersionInRange,
|
||||
});
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
||||
|
||||
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
||||
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
|
||||
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
|
||||
);
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw error if pgvecto.rs extension could not be created`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: null,
|
||||
availableVersion: minVersionInRange,
|
||||
});
|
||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
||||
|
||||
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
||||
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
|
||||
`Alternatively, if your Postgres instance has pgvector, you may use this instead`,
|
||||
);
|
||||
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,38 +403,38 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
it('should not override interval', () => {
|
||||
sut.handleConnectionError(new Error('Error'));
|
||||
expect(mocks.logger.error).toHaveBeenCalled();
|
||||
expect(loggerMock.error).toHaveBeenCalled();
|
||||
|
||||
sut.handleConnectionError(new Error('foo'));
|
||||
expect(mocks.logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reconnect when interval elapses', async () => {
|
||||
mocks.database.reconnect.mockResolvedValue(true);
|
||||
databaseMock.reconnect.mockResolvedValue(true);
|
||||
|
||||
sut.handleConnectionError(new Error('error'));
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected');
|
||||
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should try again when reconnection fails', async () => {
|
||||
mocks.database.reconnect.mockResolvedValueOnce(false);
|
||||
databaseMock.reconnect.mockResolvedValueOnce(false);
|
||||
|
||||
sut.handleConnectionError(new Error('error'));
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(mocks.database.reconnect).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed'));
|
||||
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed'));
|
||||
|
||||
mocks.database.reconnect.mockResolvedValueOnce(true);
|
||||
databaseMock.reconnect.mockResolvedValueOnce(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(mocks.database.reconnect).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith('Database reconnected');
|
||||
expect(databaseMock.reconnect).toHaveBeenCalledTimes(2);
|
||||
expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user