mirror of
https://github.com/immich-app/immich.git
synced 2025-03-11 02:23:09 -05:00
Merge branch 'main' into fix/11428
This commit is contained in:
commit
a37a2470a8
29 changed files with 408 additions and 29 deletions
2
.github/workflows/cli.yml
vendored
2
.github/workflows/cli.yml
vendored
|
@ -59,7 +59,7 @@ jobs:
|
|||
uses: docker/setup-qemu-action@v3.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.6.1
|
||||
uses: docker/setup-buildx-action@v3.7.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
|
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
@ -124,7 +124,7 @@ jobs:
|
|||
uses: docker/setup-qemu-action@v3.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.6.1
|
||||
uses: docker/setup-buildx-action@v3.7.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
# Only push to Docker Hub when making a release
|
||||
|
@ -215,7 +215,7 @@ jobs:
|
|||
uses: docker/setup-qemu-action@v3.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.6.1
|
||||
uses: docker/setup-buildx-action@v3.7.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
# Only push to Docker Hub when making a release
|
||||
|
|
|
@ -103,7 +103,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792
|
||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792
|
||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
|
|
@ -48,7 +48,7 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792
|
||||
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
|
|
@ -32,7 +32,7 @@ services:
|
|||
- 2285:3001
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792
|
||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
||||
|
||||
database:
|
||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||
|
|
16
machine-learning/poetry.lock
generated
16
machine-learning/poetry.lock
generated
|
@ -2037,22 +2037,22 @@ reference = "cuda12"
|
|||
|
||||
[[package]]
|
||||
name = "onnxruntime-openvino"
|
||||
version = "1.19.0"
|
||||
version = "1.18.0"
|
||||
description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"},
|
||||
{file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"},
|
||||
{file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"},
|
||||
{file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"},
|
||||
{file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"},
|
||||
{file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"},
|
||||
{file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"},
|
||||
{file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"},
|
||||
{file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"},
|
||||
{file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
coloredlogs = "*"
|
||||
flatbuffers = "*"
|
||||
numpy = ">=1.21.6"
|
||||
numpy = ">=1.26.4"
|
||||
packaging = "*"
|
||||
protobuf = "*"
|
||||
sympy = "*"
|
||||
|
@ -3605,4 +3605,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "b2b053886ca1dd3a3305c63caf155b1976dfc4066f72f5d1ecfc42099db34aab"
|
||||
content-hash = "45c4d57450fbdd0c24a8e7e00ebd023412fa6db04700d0f3818c39c8777d0e31"
|
||||
|
|
|
@ -51,7 +51,7 @@ onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"}
|
|||
optional = true
|
||||
|
||||
[tool.poetry.group.openvino.dependencies]
|
||||
onnxruntime-openvino = "^1.17.1"
|
||||
onnxruntime-openvino = ">=1.17.1,<1.19.0"
|
||||
|
||||
[tool.poetry.group.armnn]
|
||||
optional = true
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20241001@sha256:bb10832c2567f5625df68bb790523e85a358031ddcb3d7ac98b669f62ed8de27 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20241008@sha256:d1af54cfda17b6b653de580afdc4bdc5cb06153b269e402035ad485a4fe0262e AS dev
|
||||
|
||||
RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
|
@ -41,7 +41,7 @@ RUN npm run build
|
|||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20241001@sha256:a9a0745a486e9cbd73fa06b49168e985f8f2c1be0fca9fb0a8e06916246c7087
|
||||
FROM ghcr.io/immich-app/base-server-prod:20241008@sha256:c0cf2a16987a53d9c2f00f127415da537b5812055a6855a62e4b0abd33c4d695
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
|
|
@ -11,7 +11,7 @@ const main = async () => {
|
|||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/api/server-info/ping`, {
|
||||
const response = await fetch(`http://localhost:${port}/api/server/ping`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
|
|
@ -279,7 +279,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
faceIdsToRemove: string[],
|
||||
embeddingsToAdd?: FaceSearchEntity[],
|
||||
): Promise<void> {
|
||||
const query = this.faceSearchRepository.createQueryBuilder().select('1');
|
||||
const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy();
|
||||
if (facesToAdd.length > 0) {
|
||||
const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd);
|
||||
query.addCommonTableExpression(insertCte, 'added');
|
||||
|
@ -296,6 +296,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
if (embeddingsToAdd?.length) {
|
||||
const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore();
|
||||
query.addCommonTableExpression(embeddingCte, 'embeddings');
|
||||
query.getQuery(); // typeorm mixes up parameters without this
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
|
|
|
@ -46,6 +46,15 @@ describe(APIKeyService.name, () => {
|
|||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||
await expect(
|
||||
sut.create(
|
||||
{ ...authStub.admin, apiKey: { ...keyStub.admin, permissions: [] } },
|
||||
{ permissions: [Permission.ASSET_READ] },
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
|
|
@ -583,6 +583,12 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should run the refresh faces job', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should run the refresh metadata job', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA });
|
||||
|
|
|
@ -72,6 +72,13 @@ describe('AuthService', () => {
|
|||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('onBootstrap', () => {
|
||||
it('should init the repo', () => {
|
||||
sut.onBootstrap();
|
||||
expect(oauthMock.init).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
|||
import { DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
|
@ -25,6 +26,7 @@ describe(DownloadService.name, () => {
|
|||
let sut: DownloadService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -32,10 +34,54 @@ describe(DownloadService.name, () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, accessMock, assetMock, storageMock } = newTestService(DownloadService));
|
||||
({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService));
|
||||
});
|
||||
|
||||
describe('downloadArchive', () => {
|
||||
it('should skip asset ids that could not be found', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
finalize: vitest.fn(),
|
||||
stream: new Readable(),
|
||||
};
|
||||
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]);
|
||||
storageMock.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(archiveMock.addFile).toHaveBeenCalledTimes(1);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
|
||||
});
|
||||
|
||||
it('should log a warning if the original path could not be resolved', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
finalize: vitest.fn(),
|
||||
stream: new Readable(),
|
||||
};
|
||||
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
storageMock.realpath.mockRejectedValue(new Error('Could not read file'));
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{ ...assetStub.noResizePath, id: 'asset-1' },
|
||||
{ ...assetStub.noWebpPath, id: 'asset-2' },
|
||||
]);
|
||||
storageMock.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg');
|
||||
});
|
||||
|
||||
it('should download an archive', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
|
|||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked, beforeEach, vitest } from 'vitest';
|
||||
|
||||
|
@ -28,6 +29,15 @@ describe(SearchService.name, () => {
|
|||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getDuplicates', () => {
|
||||
it('should get duplicates', async () => {
|
||||
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]);
|
||||
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
||||
{ duplicateId: assetStub.hasDupe.duplicateId, assets: [expect.objectContaining({ id: assetStub.hasDupe.id })] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSearchDuplicates', () => {
|
||||
beforeEach(() => {
|
||||
systemMock.get.mockResolvedValue({
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IMapRepository } from 'src/interfaces/map.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
import { albumStub } from 'test/fixtures/album.stub';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { partnerStub } from 'test/fixtures/partner.stub';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(MapService.name, () => {
|
||||
let sut: MapService;
|
||||
|
||||
let albumMock: Mocked<IAlbumRepository>;
|
||||
let mapMock: Mocked<IMapRepository>;
|
||||
let partnerMock: Mocked<IPartnerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mapMock, partnerMock } = newTestService(MapService));
|
||||
({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService));
|
||||
});
|
||||
|
||||
describe('getMapMarkers', () => {
|
||||
|
@ -35,5 +39,62 @@ describe(MapService.name, () => {
|
|||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
});
|
||||
|
||||
it('should include partner assets', async () => {
|
||||
const asset = assetStub.withLocation;
|
||||
const marker = {
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
};
|
||||
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]);
|
||||
mapMock.getMapMarkers.mockResolvedValue([marker]);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true });
|
||||
|
||||
expect(mapMock.getMapMarkers).toHaveBeenCalledWith(
|
||||
[authStub.user1.user.id, partnerStub.adminToUser1.sharedById],
|
||||
expect.arrayContaining([]),
|
||||
{ withPartners: true },
|
||||
);
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
});
|
||||
|
||||
it('should include assets from shared albums', async () => {
|
||||
const asset = assetStub.withLocation;
|
||||
const marker = {
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
};
|
||||
partnerMock.getAll.mockResolvedValue([]);
|
||||
mapMock.getMapMarkers.mockResolvedValue([marker]);
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true });
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverseGeocode', () => {
|
||||
it('should reverse geocode a location', async () => {
|
||||
mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' });
|
||||
|
||||
await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([
|
||||
{ city: 'foo', state: 'bar', country: 'baz' },
|
||||
]);
|
||||
|
||||
expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -126,6 +126,14 @@ describe(NotificationService.name, () => {
|
|||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if smtp configuration is invalid', async () => {
|
||||
const oldConfig = configs.smtpDisabled;
|
||||
const newConfig = configs.smtpEnabled;
|
||||
|
||||
notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp'));
|
||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAssetHide', () => {
|
||||
|
@ -180,6 +188,18 @@ describe(NotificationService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('onSessionDeleteEvent', () => {
|
||||
it('should send a on_session_delete client event', () => {
|
||||
vi.useFakeTimers();
|
||||
sut.onSessionDelete({ sessionId: 'id' });
|
||||
expect(eventMock.clientSend).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAssetTrash', () => {
|
||||
it('should send connected clients an event', () => {
|
||||
sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' });
|
||||
|
|
|
@ -3,15 +3,18 @@ import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.int
|
|||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { partnerStub } from 'test/fixtures/partner.stub';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(PartnerService.name, () => {
|
||||
let sut: PartnerService;
|
||||
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let partnerMock: Mocked<IPartnerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, partnerMock } = newTestService(PartnerService));
|
||||
({ sut, accessMock, partnerMock } = newTestService(PartnerService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -71,4 +74,24 @@ describe(PartnerService.name, () => {
|
|||
expect(partnerMock.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: false })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id']));
|
||||
partnerMock.update.mockResolvedValue(partnerStub.adminToUser1);
|
||||
|
||||
await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined();
|
||||
expect(partnerMock.update).toHaveBeenCalledWith({
|
||||
sharedById: 'shared-by-id',
|
||||
sharedWithId: authStub.admin.user.id,
|
||||
inTimeline: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -325,7 +325,7 @@ export class PersonService extends BaseService {
|
|||
|
||||
if (match && !mlFaceIds.delete(match.id)) {
|
||||
embeddings.push({ faceId: match.id, embedding });
|
||||
} else {
|
||||
} else if (!match) {
|
||||
const faceId = this.cryptoRepository.randomUUID();
|
||||
facesToAdd.push({
|
||||
id: faceId,
|
||||
|
|
|
@ -58,12 +58,21 @@ describe(SharedLinkService.name, () => {
|
|||
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
it('should throw an error for an password protected shared link', async () => {
|
||||
it('should throw an error for an invalid password protected shared link', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
|
||||
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
it('should allow a correct password on a password protected shared link', async () => {
|
||||
sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
|
||||
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined();
|
||||
expect(sharedLinkMock.get).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.user.id,
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
|
@ -300,5 +309,15 @@ describe(SharedLinkService.name, () => {
|
|||
});
|
||||
expect(sharedLinkMock.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return metadata tags with a default image path if the asset id is not set', async () => {
|
||||
sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
|
||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '0 shared photos & videos',
|
||||
imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/feature-panel.png`,
|
||||
title: 'Public Share',
|
||||
});
|
||||
expect(sharedLinkMock.get).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,7 +20,7 @@ import { OpenGraphTags } from 'src/utils/misc';
|
|||
|
||||
@Injectable()
|
||||
export class SharedLinkService extends BaseService {
|
||||
getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
|
||||
async getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
|
||||
return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { SystemConfig } from 'src/config';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
|
@ -16,13 +17,15 @@ describe(SmartInfoService.name, () => {
|
|||
let sut: SmartInfoService;
|
||||
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let databaseMock: Mocked<IDatabaseRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let machineLearningMock: Mocked<IMachineLearningRepository>;
|
||||
let searchMock: Mocked<ISearchRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, assetMock, jobMock, machineLearningMock, searchMock, systemMock } = newTestService(SmartInfoService));
|
||||
({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock } =
|
||||
newTestService(SmartInfoService));
|
||||
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
});
|
||||
|
@ -317,6 +320,30 @@ describe(SmartInfoService.name, () => {
|
|||
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
|
||||
expect(searchMock.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if asset could not be found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED);
|
||||
|
||||
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
|
||||
expect(searchMock.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait for database', async () => {
|
||||
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||
databaseMock.isBusy.mockReturnValue(true);
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
|
||||
|
||||
expect(databaseMock.wait).toHaveBeenCalledWith(512);
|
||||
expect(machineLearningMock.encodeImage).toHaveBeenCalledWith(
|
||||
'http://immich-machine-learning:3003',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCLIPModelInfo', () => {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { ImmichStartupError, StorageService } from 'src/services/storage.service';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
@ -11,11 +12,12 @@ describe(StorageService.name, () => {
|
|||
let sut: StorageService;
|
||||
|
||||
let configMock: Mocked<IConfigRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, configMock, storageMock, systemMock } = newTestService(StorageService));
|
||||
({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -59,6 +61,25 @@ describe(StorageService.name, () => {
|
|||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip mount file creation if file already exists', async () => {
|
||||
const error = new Error('Error creating file') as any;
|
||||
error.code = 'EEXIST';
|
||||
systemMock.get.mockResolvedValue({ mountFiles: false });
|
||||
storageMock.createFile.mockRejectedValue(error);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation');
|
||||
});
|
||||
|
||||
it('should throw an error if mount file could not be created', async () => {
|
||||
systemMock.get.mockResolvedValue({ mountFiles: false });
|
||||
storageMock.createFile.mockRejectedValue(new Error('Error creating file'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError);
|
||||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should startup if checks are disabled', async () => {
|
||||
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||
configMock.getEnv.mockReturnValue(
|
||||
|
|
|
@ -16,6 +16,19 @@ describe(SystemMetadataService.name, () => {
|
|||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAdminOnboarding', () => {
|
||||
it('should get isOnboarded state', async () => {
|
||||
systemMock.get.mockResolvedValue({ isOnboarded: true });
|
||||
await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true });
|
||||
expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding');
|
||||
});
|
||||
|
||||
it('should default isOnboarded to false', async () => {
|
||||
await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false });
|
||||
expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAdminOnboarding', () => {
|
||||
it('should update isOnboarded to true', async () => {
|
||||
await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
|
||||
|
@ -27,4 +40,21 @@ describe(SystemMetadataService.name, () => {
|
|||
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReverseGeocodingState', () => {
|
||||
it('should get reverse geocoding state', async () => {
|
||||
systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' });
|
||||
await expect(sut.getReverseGeocodingState()).resolves.toEqual({
|
||||
lastUpdate: '2024-01-01',
|
||||
lastImportFileName: 'foo.bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('should default reverse geocoding state to null', async () => {
|
||||
await expect(sut.getReverseGeocodingState()).resolves.toEqual({
|
||||
lastUpdate: null,
|
||||
lastImportFileName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
|
@ -261,4 +262,11 @@ describe(TagService.name, () => {
|
|||
expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleTagCleanup', () => {
|
||||
it('should delete empty tags', async () => {
|
||||
await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(tagMock.deleteEmptyTags).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -69,6 +69,70 @@ describe(TimelineService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should include partner shared assets', async () => {
|
||||
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: false,
|
||||
userId: authStub.admin.user.id,
|
||||
withPartners: true,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: false,
|
||||
withPartners: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
});
|
||||
|
||||
it('should check permissions to read tag', async () => {
|
||||
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userId: authStub.admin.user.id,
|
||||
tagId: 'tag-123',
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
tagId: 'tag-123',
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip metadata if showExif is disabled', async () => {
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
||||
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
const buckets = await sut.getTimeBucket(
|
||||
{ ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
||||
{
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
albumId: 'album-id',
|
||||
},
|
||||
);
|
||||
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
||||
expect(buckets[0]).not.toHaveProperty('exif');
|
||||
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
albumId: 'album-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the assets for a library time bucket if user has library.read', async () => {
|
||||
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichEnvironment, SystemMetadataKey } from 'src/enum';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
|
@ -103,6 +104,11 @@ describe(VersionService.name, () => {
|
|||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it('should not run if version check is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } });
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it('should run if it has been > 60 minutes', async () => {
|
||||
serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0'));
|
||||
systemMock.get.mockResolvedValue({
|
||||
|
@ -133,4 +139,19 @@ describe(VersionService.name, () => {
|
|||
expect(loggerMock.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnectionEvent', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(eventMock.clientSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should also send a new release notification', async () => {
|
||||
systemMock.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' });
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,12 @@ export default defineConfig({
|
|||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'],
|
||||
exclude: [
|
||||
'src/services/*.spec.ts',
|
||||
'src/services/api.service.ts',
|
||||
'src/services/microservices.service.ts',
|
||||
'src/services/index.ts',
|
||||
],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
|
|
Loading…
Add table
Reference in a new issue