From d2800a647c53df37c5494480c7135dafd1c3888f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:22:04 -0400 Subject: [PATCH 1/8] chore(deps): update base-image to v20241008 (major) (#13284) chore(deps): update base-image to v20241008 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 4ebae191e9..19383e1b0f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 \ From bff3690a2f887278b5091e20f5223db136ca0de1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:26:53 -0400 Subject: [PATCH 2/8] chore(deps): update docker/setup-buildx-action action to v3.7.0 (#13281) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a86408eea8..94d33b6006 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8c7aeb020e..b9e7138f95 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 From 9a6fd1c3ff7ffcb5925a52282628a345b9ec50ae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:27:27 -0400 Subject: [PATCH 3/8] chore(deps): update docker.io/redis:6.2-alpine docker digest to 2ba50e1 (#13265) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eec723dc08..3072c96c58 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 From f5e0cdedbc41657c8d737e894077b1db2a243abf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:27:47 -0400 Subject: [PATCH 4/8] chore(deps): update redis:6.2-alpine docker digest to 2ba50e1 (#13266) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 066dc9c701..3d0f72425f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 812bae45b3..299516d16f 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -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 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 6169a4bfa1..00be9162a2 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -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 From 9d0f03808c2041cc7d36372e50ccf70cdf01accf Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 8 Oct 2024 23:08:49 +0200 Subject: [PATCH 5/8] chore: finishing unit tests for a couple of services (#13292) --- server/src/services/api-key.service.spec.ts | 9 +++ server/src/services/asset.service.spec.ts | 6 ++ server/src/services/auth.service.spec.ts | 7 ++ server/src/services/download.service.spec.ts | 48 +++++++++++++- server/src/services/duplicate.service.spec.ts | 10 +++ server/src/services/map.service.spec.ts | 63 +++++++++++++++++- .../src/services/notification.service.spec.ts | 20 ++++++ server/src/services/partner.service.spec.ts | 25 +++++++- .../src/services/shared-link.service.spec.ts | 21 +++++- server/src/services/shared-link.service.ts | 2 +- .../src/services/smart-info.service.spec.ts | 29 ++++++++- server/src/services/storage.service.spec.ts | 25 +++++++- .../services/system-metadata.service.spec.ts | 30 +++++++++ server/src/services/tag.service.spec.ts | 8 +++ server/src/services/timeline.service.spec.ts | 64 +++++++++++++++++++ server/src/services/version.service.spec.ts | 21 ++++++ server/vitest.config.mjs | 6 ++ 17 files changed, 386 insertions(+), 8 deletions(-) diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index c0a24c1aaf..3841ba1be9 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -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', () => { diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 3314859b49..9063df9dc2 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -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 }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3a5df0790d..f45affe042 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 2bebd5c320..632d157384 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -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; + let loggerMock: Mocked; let storageMock: Mocked; 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(), diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index dd2656d34b..095d53dde6 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -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({ diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index ec3fb00dc8..fde2ba7e0f 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -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; let mapMock: Mocked; let partnerMock: Mocked; 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 }); + }); }); }); diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 548980bdd9..028e512b39 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -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' }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index a1433eed4d..2e11c4f9ad 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -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; 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, + }); + }); + }); }); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 4e54108f6d..d0959f31b8 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -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(); + }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 3116f0554b..5676531e57 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -20,7 +20,7 @@ import { OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { - getAll(auth: AuthDto): Promise { + async getAll(auth: AuthDto): Promise { return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); } diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 30b8ce7ec3..f53822a9e2 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -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; + let databaseMock: Mocked; let jobMock: Mocked; let machineLearningMock: Mocked; let searchMock: Mocked; let systemMock: Mocked; 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', () => { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 45a18d8c4b..a4903a3987 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -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; + let loggerMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; 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( diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index f17b398ab1..3dc2f0a6bb 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -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, + }); + }); + }); }); diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 69840a83da..54cef40d04 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -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(); + }); + }); }); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 3de7f71fae..db6890c27b 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -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]); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 70a58059d1..46f8f620c4 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -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)); + }); + }); }); diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 1013b4606d..ff893736af 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -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, From 08d428cbce989bcd3b594c693d46229e069dea27 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:37:41 -0400 Subject: [PATCH 6/8] fix(server): duplicate faces, face insert query failing (#13294) fix duplicate faces, query failing --- server/src/repositories/person.repository.ts | 3 ++- server/src/services/person.service.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 64ae548c1b..3ba9e23887 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -279,7 +279,7 @@ export class PersonRepository implements IPersonRepository { faceIdsToRemove: string[], embeddingsToAdd?: FaceSearchEntity[], ): Promise { - 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(); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 77694d8e5d..624fb46b6d 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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, From 3ba2602664812879ab3779c8f0ab8d1b786012d5 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 8 Oct 2024 18:03:28 -0400 Subject: [PATCH 7/8] fix(ml): pin onnxruntime-openvino (#13290) --- machine-learning/poetry.lock | 16 ++++++++-------- machine-learning/pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index f28f039dd4..2afa60e701 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -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" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 237fa231e9..fec660a79f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -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 From 4780bb4fcdccee23822a87e4c8a62bea0c801381 Mon Sep 17 00:00:00 2001 From: itoktsnhc Date: Wed, 9 Oct 2024 12:21:31 +0800 Subject: [PATCH 8/8] fix(server): fix server ping URL path mismatch in healthcheck.ts (#13297) fix server ping URL path mismatch in healthcheck.ts --- server/src/bin/healthcheck.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/bin/healthcheck.ts b/server/src/bin/healthcheck.ts index b38d9d17df..6de58c2002 100644 --- a/server/src/bin/healthcheck.ts +++ b/server/src/bin/healthcheck.ts @@ -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, });