From 4adedea128cdec35f1d30a5afe8ae88cd27d1823 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 4 Oct 2024 19:37:18 +0200 Subject: [PATCH] chore: asset service unit tests (#13179) --- server/src/services/asset.service.spec.ts | 230 +++++++++++++++++++++- 1 file changed, 228 insertions(+), 2 deletions(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index f8e5ff347e548..3314859b49092 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,13 +1,15 @@ import { BadRequestException } from '@nestjs/common'; +import { DateTime } from 'luxon'; 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, IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.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'; @@ -41,6 +43,7 @@ describe(AssetService.name, () => { let jobMock: Mocked; let partnerMock: Mocked; let stackMock: Mocked; + let systemMock: Mocked; let userMock: Mocked; it('should work', () => { @@ -54,7 +57,7 @@ describe(AssetService.name, () => { }; beforeEach(() => { - ({ sut, accessMock, assetMock, eventMock, jobMock, userMock, partnerMock, stackMock } = + ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); @@ -126,6 +129,28 @@ describe(AssetService.name, () => { }); }); + describe('getRandom', () => { + it('should get own random assets', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + }); + + it('should not include partner assets if not in timeline', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + }); + + it('should include partner assets if in timeline', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); + }); + }); + describe('get', () => { it('should allow owner access', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); @@ -147,6 +172,23 @@ describe(AssetService.name, () => { ); }); + it('should strip metadata for shared link if exif is disabled', async () => { + 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 } }, + assetStub.image.id, + ); + + expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); + expect(result).not.toHaveProperty('exifInfo'); + expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLink?.id, + new Set([assetStub.image.id]), + ); + }); + it('should allow partner sharing access', async () => { accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); assetMock.getById.mockResolvedValue(assetStub.image); @@ -177,6 +219,11 @@ describe(AssetService.name, () => { expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(assetMock.getById).not.toHaveBeenCalled(); }); + + it('should throw an error if the asset could not be found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('update', () => { @@ -207,6 +254,132 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, '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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + 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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + 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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + 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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValueOnce({ + ...assetStub.livePhotoMotionAsset, + ownerId: authStub.admin.user.id, + isVisible: true, + }); + assetMock.getById.mockResolvedValueOnce(assetStub.image); + + await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + + 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(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 () => { + 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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + assetMock.getById.mockResolvedValueOnce(assetStub.image); + + await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + + expect(assetMock.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: null, + }); + 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 () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(eventMock.emit).not.toHaveBeenCalledWith(); + }); }); describe('updateAll', () => { @@ -259,6 +432,42 @@ describe(AssetService.name, () => { }); }); + describe('handleAssetDeletionCheck', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should immediately queue assets for deletion if trash is disabled', async () => { + assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + systemMock.get.mockResolvedValue({ trash: { enabled: false } }); + + await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); + + 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 () => { + 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(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { + trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), + }); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + ]); + }); + }); + describe('handleAssetDeletion', () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; @@ -298,6 +507,17 @@ describe(AssetService.name, () => { }); }); + it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { + 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(stackMock.delete).toHaveBeenCalledWith('stack-1'); + }); + it('should delete a live photo', async () => { assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getLivePhotoCount.mockResolvedValue(0); @@ -354,6 +574,12 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); + + it('should fail if asset could not be found', async () => { + await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( + JobStatus.FAILED, + ); + }); }); describe('run', () => {