From 7cb355279e94cb599f967bac6433212c2b73152e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:00:31 +0100 Subject: [PATCH] chore: remove asset stubs (#26187) --- server/src/dtos/asset-response.dto.spec.ts | 126 +++----- .../src/services/asset-media.service.spec.ts | 94 +++--- server/src/services/asset.service.spec.ts | 17 +- server/src/services/duplicate.service.spec.ts | 9 +- server/src/services/map.service.spec.ts | 56 ++-- server/src/services/media.service.spec.ts | 20 +- server/src/services/metadata.service.spec.ts | 40 +-- server/src/services/search.service.spec.ts | 17 +- server/src/services/stack.service.spec.ts | 87 +++-- .../services/storage-template.service.spec.ts | 303 ++++++++++++------ server/test/factories/asset-face.factory.ts | 47 +++ server/test/factories/asset.factory.ts | 33 +- server/test/factories/person.factory.ts | 34 ++ server/test/factories/stack.factory.ts | 52 +++ server/test/factories/types.ts | 6 + server/test/fixtures/asset.stub.ts | 298 ----------------- server/test/fixtures/person.stub.ts | 18 +- server/test/mappers.ts | 22 ++ 18 files changed, 624 insertions(+), 655 deletions(-) create mode 100644 server/test/factories/asset-face.factory.ts create mode 100644 server/test/factories/person.factory.ts create mode 100644 server/test/factories/stack.factory.ts delete mode 100644 server/test/fixtures/asset.stub.ts create mode 100644 server/test/mappers.ts diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts index e71ffdadd2..ff3b3f6acd 100644 --- a/server/src/dtos/asset-response.dto.spec.ts +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -1,14 +1,14 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { faceStub } from 'test/fixtures/face.stub'; -import { personStub } from 'test/fixtures/person.stub'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { PersonFactory } from 'test/factories/person.factory'; describe('mapAsset', () => { describe('peopleWithFaces', () => { it('should transform all faces when a person has multiple faces in the same image', () => { + const person = PersonFactory.create(); const face1 = { - ...faceStub.primaryFace1, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -18,8 +18,6 @@ describe('mapAsset', () => { }; const face2 = { - ...faceStub.primaryFace1, - id: 'assetFaceId-second', boundingBoxX1: 300, boundingBoxY1: 400, boundingBoxX2: 400, @@ -28,16 +26,22 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [face1, face2], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - }; + const asset = AssetFactory.from() + .face(face1, (builder) => builder.person(person)) + .face(face2, (builder) => builder.person(person)) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .edit({ + action: AssetEditAction.Crop, + parameters: { + width: 1512, + height: 1152, + x: 216, + y: 1512, + }, + }) + .build(); - const result = mapAsset(asset as any); + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); @@ -61,32 +65,22 @@ describe('mapAsset', () => { }); it('should transform unassigned faces with edits and dimensions', () => { - const unassignedFace = { - ...faceStub.noPerson1, + const unassignedFace = AssetFaceFactory.create({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200, imageWidth: 1000, imageHeight: 800, - }; + }); - const asset = { - ...assetStub.withCropEdit, - faces: [unassignedFace], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [ - { - action: AssetEditAction.Crop, - parameters: { x: 50, y: 50, width: 500, height: 400 }, - }, - ], - }; + const asset = AssetFactory.from() + .face(unassignedFace) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } }) + .build(); - const result = mapAsset(asset as any); + const result = mapAsset(asset); expect(result.unassignedFaces).toBeDefined(); expect(result.unassignedFaces).toHaveLength(1); @@ -101,10 +95,6 @@ describe('mapAsset', () => { it('should handle multiple people each with multiple faces', () => { const person1Face1 = { - ...faceStub.primaryFace1, - id: 'face-1-1', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -114,10 +104,6 @@ describe('mapAsset', () => { }; const person1Face2 = { - ...faceStub.primaryFace1, - id: 'face-1-2', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 300, boundingBoxY1: 300, boundingBoxX2: 400, @@ -127,10 +113,6 @@ describe('mapAsset', () => { }; const person2Face1 = { - ...faceStub.mergeFace1, - id: 'face-2-1', - person: personStub.mergePerson, - personId: personStub.mergePerson.id, boundingBoxX1: 500, boundingBoxY1: 100, boundingBoxX2: 600, @@ -139,23 +121,22 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [person1Face1, person1Face2, person2Face1], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [], - }; + const person = PersonFactory.create({ id: 'person-1' }); - const result = mapAsset(asset as any); + const asset = AssetFactory.from() + .face(person1Face1, (builder) => builder.person(person)) + .face(person1Face2, (builder) => builder.person(person)) + .face(person2Face1, (builder) => builder.person({ id: 'person-2' })) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .build(); + + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(2); - const person1 = result.people!.find((p) => p.id === personStub.withName.id); - const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id); + const person1 = result.people!.find((p) => p.id === 'person-1'); + const person2 = result.people!.find((p) => p.id === 'person-2'); expect(person1).toBeDefined(); expect(person1!.faces).toHaveLength(2); @@ -173,10 +154,6 @@ describe('mapAsset', () => { it('should combine faces of the same person into a single entry', () => { const face1 = { - ...faceStub.primaryFace1, - id: 'face-1', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, @@ -186,10 +163,6 @@ describe('mapAsset', () => { }; const face2 = { - ...faceStub.primaryFace1, - id: 'face-2', - person: personStub.withName, - personId: personStub.withName.id, boundingBoxX1: 300, boundingBoxY1: 300, boundingBoxX2: 400, @@ -198,24 +171,21 @@ describe('mapAsset', () => { imageHeight: 800, }; - const asset = { - ...assetStub.withCropEdit, - faces: [face1, face2], - exifInfo: { - exifImageWidth: 1000, - exifImageHeight: 800, - }, - edits: [], - }; + const person = PersonFactory.create(); - const result = mapAsset(asset as any); + const asset = AssetFactory.from() + .face(face1, (builder) => builder.person(person)) + .face(face2, (builder) => builder.person(person)) + .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) + .build(); + + const result = mapAsset(asset); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); - const person = result.people![0]; - expect(person.id).toBe(personStub.withName.id); - expect(person.faces).toHaveLength(2); + expect(result.people![0].id).toBe(person.id); + expect(result.people![0].faces).toHaveLength(2); }); }); }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 918ea65793..84440fd4b6 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -18,7 +18,7 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ImmichFileResponse } from 'src/utils/file'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -515,28 +515,19 @@ describe(AssetMediaService.name, () => { }); it('should download edited file by default when edits exist', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path }); + + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, {})).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.files[3].path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -544,28 +535,19 @@ describe(AssetMediaService.name, () => { }); it('should download edited file when edited=true', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path }); + + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: true })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.files[3].path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -579,7 +561,9 @@ describe(AssetMediaService.name, () => { mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id])); mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path }); - await expect(sut.downloadOriginal(authStub.adminSharedLink, editedAsset.id, { edited: false })).resolves.toEqual( + await expect( + sut.downloadOriginal(AuthFactory.from().sharedLink().build(), editedAsset.id, { edited: false }), + ).resolves.toEqual( new ImmichFileResponse({ path: fullsizeEdited.path, fileName: editedAsset.originalFileName, @@ -590,25 +574,19 @@ describe(AssetMediaService.name, () => { }); it('should download original file when edited=false', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + const editedAsset = AssetFactory.from() + .edit() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .file({ type: AssetFileType.FullSize, isEdited: true }) + .build(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id])); mocks.asset.getForOriginal.mockResolvedValue(editedAsset); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual( + await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: false })).resolves.toEqual( new ImmichFileResponse({ - path: '/original/path.jpg', - fileName: 'asset-id.jpg', + path: editedAsset.originalPath, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 9d2f130d86..b677881cfe 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,7 +7,6 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -586,19 +585,19 @@ 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 () => { + const asset = AssetFactory.from() + .stack({}, (builder) => builder.asset()) + .build(); mocks.stack.delete.mockResolvedValue(); mocks.assetJob.getForAssetDeletion.mockResolvedValue({ - ...assetStub.primaryImage, - stack: { - id: 'stack-id', - primaryAssetId: assetStub.primaryImage.id, - assets: [{ id: 'one-asset' }], - }, + ...asset, + // TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually + stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) }, }); - await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); - expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); }); it('should delete a live photo', async () => { diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 16c54fc15e..0b216e8b8a 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -2,7 +2,6 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -184,13 +183,13 @@ describe(SearchService.name, () => { }); it('should skip if asset is part of stack', async () => { - const id = assetStub.primaryImage.id; - mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' }); + const asset = AssetFactory.from().stack().build(); + mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId }); - const result = await sut.handleSearchDuplicates({ id }); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Skipped); - expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`); }); it('should skip if asset is not visible', async () => { diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index e7369569d2..d58ae67140 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,7 +1,7 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; -import { authStub } from 'test/fixtures/auth.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -16,36 +16,41 @@ describe(MapService.name, () => { describe('getMapMarkers', () => { it('should get geo information of assets', async () => { - const asset = assetStub.withLocation; + const auth = AuthFactory.create(); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); - const markers = await sut.getMapMarkers(authStub.user1, {}); + const markers = await sut.getMapMarkers(auth, {}); expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); }); it('should include partner assets', async () => { - const partner = factory.partner(); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const auth = AuthFactory.create(); + const partner = factory.partner({ sharedWithId: auth.user.id }); - const asset = assetStub.withLocation; + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([partner]); mocks.map.getMapMarkers.mockResolvedValue([marker]); @@ -62,21 +67,24 @@ describe(MapService.name, () => { }); it('should include assets from shared albums', async () => { - const asset = assetStub.withLocation; + const auth = AuthFactory.create(userStub.user1); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); 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, + lat: asset.exifInfo.latitude!, + lon: asset.exifInfo.longitude!, + city: asset.exifInfo.city, + state: asset.exifInfo.state, + country: asset.exifInfo.country, }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]); mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]); - const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); + const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 4e0f4e246d..bf2cbc62fa 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -22,7 +22,6 @@ import { import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; @@ -205,7 +204,8 @@ describe(MediaService.name, () => { }); it('should queue assets with edits but missing edited thumbnails', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + const asset = AssetFactory.from().edit().build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -213,7 +213,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetEditThumbnailGeneration, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, ]); @@ -221,8 +221,9 @@ describe(MediaService.name, () => { }); it('should not queue assets with missing edited fullsize when feature is disabled', async () => { + const asset = AssetFactory.from().edit().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -251,7 +252,8 @@ describe(MediaService.name, () => { }); it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + const asset = AssetFactory.from().edit().build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -259,11 +261,11 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, { name: JobName.AssetEditThumbnailGeneration, - data: { id: assetStub.withCropEdit.id }, + data: { id: asset.id }, }, ]); @@ -1504,7 +1506,7 @@ describe(MediaService.name, () => { expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, orientation: undefined, processInvalidImages: false, @@ -2193,7 +2195,7 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = assetStub.hasEncodedVideo; + const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index d60a9b8487..8530f6fed2 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -16,7 +16,6 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; @@ -1227,16 +1226,17 @@ describe(MetadataService.name, () => { expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); - it('should apply metadata face tags creating new persons', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + it('should apply metadata face tags creating new people', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); expect(mocks.person.createAll).toHaveBeenCalledWith([ expect.objectContaining({ name: personStub.withName.name }), ]); @@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => { [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: 'random-uuid', imageHeight: 100, imageWidth: 1000, @@ -1258,7 +1258,7 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, + { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -1269,21 +1269,22 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: personStub.withName.id, imageHeight: 100, imageWidth: 1000, @@ -1353,16 +1354,17 @@ describe(MetadataService.name, () => { 'should transform RegionInfo geometry according to exif orientation $description', async ({ orientation, expected }) => { const { imgW, imgH, x1, x2, y1, y2 } = expected; + const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); - await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id); - expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); + expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true, }); expect(mocks.person.createAll).toHaveBeenCalledWith([ @@ -1372,7 +1374,7 @@ describe(MetadataService.name, () => { [ { id: 'random-uuid', - assetId: assetStub.primaryImage.id, + assetId: asset.id, personId: 'random-uuid', imageWidth: imgW, imageHeight: imgH, @@ -1386,7 +1388,7 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, + { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 0dec02f18f..5f1125eaed 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; import { SearchService } from 'src/services/search.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -64,16 +65,18 @@ describe(SearchService.name, () => { describe('getExploreData', () => { it('should get assets by city and tag', async () => { + const auth = AuthFactory.create(); + const asset = AssetFactory.from() + .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) + .build(); mocks.asset.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', - items: [{ value: 'test-city', data: assetStub.withLocation.id }], + items: [{ value: 'city', data: asset.id }], }); - mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]); - const expectedResponse = [ - { fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] }, - ]; + mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]); + const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }]; - const result = await sut.getExploreData(authStub.user1); + const result = await sut.getExploreData(auth); expect(result).toEqual(expectedResponse); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index fa30ba39e3..93f84e28e1 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,7 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { StackService } from 'src/services/stack.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { stackStub } from 'test/fixtures/asset.stub'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { StackFactory } from 'test/factories/stack.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -20,12 +21,14 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { + const auth = AuthFactory.create(); const asset = AssetFactory.create(); - mocks.stack.search.mockResolvedValue([stackStub('stack-id', [asset])]); + const stack = StackFactory.from().primaryAsset(asset).build(); + mocks.stack.search.mockResolvedValue([stack]); - await sut.search(authStub.admin, { primaryAssetId: asset.id }); + await sut.search(auth, { primaryAssetId: asset.id }); expect(mocks.stack.search).toHaveBeenCalledWith({ - ownerId: authStub.admin.user.id, + ownerId: auth.user.id, primaryAssetId: asset.id, }); }); @@ -33,8 +36,10 @@ describe(StackService.name, () => { describe('create', () => { it('should require asset.update permissions', async () => { + const auth = AuthFactory.create(); const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf( + + await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf( BadRequestException, ); @@ -43,18 +48,22 @@ describe(StackService.name, () => { }); it('should create a stack', async () => { + const auth = AuthFactory.create(); const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id])); - mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); - await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ - id: 'stack-id', + mocks.stack.create.mockResolvedValue(stack); + + await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ + id: stack.id, primaryAssetId: primaryAsset.id, assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', { - stackId: 'stack-id', - userId: authStub.admin.user.id, + stackId: stack.id, + userId: auth.user.id, }); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); }); @@ -78,23 +87,26 @@ describe(StackService.name, () => { }); it('should get stack', async () => { + const auth = AuthFactory.create(); const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); - await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ - id: 'stack-id', + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + + await expect(sut.get(auth, stack.id)).resolves.toEqual({ + id: stack.id, primaryAssetId: primaryAsset.id, assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); }); }); describe('update', () => { it('should require stack.update permissions', async () => { - await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.stack.getById).not.toHaveBeenCalled(); expect(mocks.stack.update).not.toHaveBeenCalled(); @@ -104,7 +116,7 @@ describe(StackService.name, () => { it('should fail if stack could not be found', async () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); + await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(mocks.stack.update).not.toHaveBeenCalled(); @@ -112,57 +124,64 @@ describe(StackService.name, () => { }); it('should fail if the provided primary asset id is not in the stack', async () => { - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); + const auth = AuthFactory.create(); + const stack = StackFactory.from().primaryAsset().asset().build(); - await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + + await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, ); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); expect(mocks.stack.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should update stack', async () => { + const auth = AuthFactory.create(); const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); - mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); + const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); - await sut.update(authStub.admin, 'stack-id', { primaryAssetId: asset.id }); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); + mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.update.mockResolvedValue(stack); - expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); - expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { - id: 'stack-id', + await sut.update(auth, stack.id, { primaryAssetId: asset.id }); + + expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id); + expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, { + id: stack.id, primaryAssetId: asset.id, }); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { - stackId: 'stack-id', - userId: authStub.admin.user.id, + stackId: stack.id, + userId: auth.user.id, }); }); }); describe('delete', () => { it('should require stack.delete permissions', async () => { - await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.stack.delete).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it('should delete stack', async () => { + const auth = AuthFactory.create(); + mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); mocks.stack.delete.mockResolvedValue(); - await sut.delete(authStub.admin, 'stack-id'); + await sut.delete(auth, 'stack-id'); expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id'); expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', { stackId: 'stack-id', - userId: authStub.admin.user.id, + userId: auth.user.id, }); }); }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index a7ac1e0301..09e0c10b80 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,16 +1,14 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; -import { AssetPathType, JobStatus } from 'src/enum'; +import { AssetPathType, AssetType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; +import { getForStorageTemplate } from 'test/mappers'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -const motionAsset = assetStub.storageAsset({}); -const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id }); - describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let mocks: ServiceMocks; @@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => { }); it('should migrate single moving picture', async () => { + const motionAsset = AssetFactory.from({ + type: AssetType.Video, + + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const stillAsset = AssetFactory.from({ + livePhotoVideoId: motionAsset.id, + + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + mocks.user.get.mockResolvedValue(userStub.user1); - const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`; const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset)); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => { }); it('should use handlebar if condition for album', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const album = AlbumFactory.from().asset().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; @@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); mocks.album.getByAssetId.mockResolvedValueOnce([album]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -166,14 +179,14 @@ describe(StorageTemplateService.name, () => { }); it('should use handlebar else condition for album', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => { }); it('should handle album startDate', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const album = AlbumFactory.from().asset().build(); const config = structuredClone(defaults); config.storageTemplate.template = @@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); mocks.album.getByAssetId.mockResolvedValueOnce([album]); mocks.album.getMetadataForIds.mockResolvedValueOnce([ { @@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => { }); it('should handle else condition from album startDate', async () => { - const asset = assetStub.storageAsset(); - const user = userStub.user1; + const user = UserFactory.create(); + const asset = AssetFactory.from().owner(user).exif().build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}'; @@ -234,7 +247,7 @@ describe(StorageTemplateService.name, () => { sut.onConfigInit({ newConfig: config }); mocks.user.get.mockResolvedValue(user); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -248,11 +261,18 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from original path when it still exists', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); - const asset = assetStub.storageAsset(); - const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; - const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`; + mocks.user.get.mockResolvedValue(user); + + const previousFailedNewPath = `/data/library/${user.id}/2023/Feb/${asset.originalFileName}`; + const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath)); mocks.move.getByEntity.mockResolvedValue({ @@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, @@ -288,9 +308,16 @@ describe(StorageTemplateService.name, () => { }); it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif({ fileSizeInByte: 5000 }) + .build(); + + mocks.user.get.mockResolvedValue(user); - const asset = assetStub.storageAsset({ fileSizeInByte: 5000 }); const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; @@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, @@ -325,45 +352,53 @@ describe(StorageTemplateService.name, () => { }); it('should fail move if copying and hash of asset and the new file do not match', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); - const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`; + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.user.get.mockResolvedValue(user); + const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8')); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset)); mocks.move.create.mockResolvedValue({ id: '123', - entityId: testAsset.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: testAsset.originalPath, + oldPath: asset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success); - expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id); + expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: testAsset.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: testAsset.originalPath, + oldPath: asset.originalPath, newPath, }); - expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath); - expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath); + expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath); + expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath); expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(mocks.asset.update).not.toHaveBeenCalled(); }); - const testAsset = assetStub.storageAsset(); + const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build(); it.each` - failedPathChecksum | failedPathSize | reason - ${testAsset.checksum} | ${500} | ${'file size'} - ${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'} + failedPathChecksum | failedPathSize | reason + ${testAsset.checksum} | ${500} | ${'file size'} + ${Buffer.from('bad checksum', 'utf8')} | ${testAsset.exifInfo.fileSizeInByte} | ${'checksum'} `( 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails', async ({ failedPathChecksum, failedPathSize }) => { @@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => { oldPath: testAsset.originalPath, newPath: previousFailedNewPath, }); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset)); mocks.move.update.mockResolvedValue({ id: '123', entityId: testAsset.id, @@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => { }); it('should handle an asset with a duplicate destination', async () => { - const asset = assetStub.storageAsset(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; const newPath2 = newPath.replace('.jpg', '+1.jpg'); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ id: '123', @@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => { }); it('should skip when an asset already matches the template', async () => { - const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' }); + const asset = AssetFactory.from({ + originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg', + }) + .exif() + .build(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); @@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => { }); it('should skip when an asset is probably a duplicate', async () => { - const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' }); + const asset = AssetFactory.from({ + originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg', + }) + .exif() + .build(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); await sut.handleMigration(); @@ -471,10 +519,15 @@ describe(StorageTemplateService.name, () => { }); it('should move an asset', async () => { - const asset = assetStub.storageAsset(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ id: '123', @@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => { }); it('should use the user storage label', async () => { - const user = factory.userAdmin({ storageLabel: 'label-1' }); - const asset = assetStub.storageAsset({ ownerId: user.id }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create({ storageLabel: 'label-1' }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', + asset.originalPath, expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).toHaveBeenCalledWith({ @@ -520,10 +579,16 @@ describe(StorageTemplateService.name, () => { }); it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { - const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + originalPath: '/path/to/original.jpg', + }) + .exif({ fileSizeInByte: 5000 }) + .build(); + const oldPath = asset.originalPath; - const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ @@ -561,10 +626,17 @@ describe(StorageTemplateService.name, () => { }); it('should not update the database if the move fails due to incorrect newPath filesize', async () => { - const asset = assetStub.storageAsset(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); - mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, @@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.copyFile).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.stat).toHaveBeenCalledWith( - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should not update the database if the move fails', async () => { - const asset = assetStub.storageAsset(); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.storage.rename.mockRejectedValue(new Error('Read only system')); mocks.storage.copyFile.mockRejectedValue(new Error('Read only system')); mocks.move.create.mockResolvedValue({ @@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => { oldPath: asset.originalPath, newPath: '', }); - mocks.user.getList.mockResolvedValue([userStub.user1]); + mocks.user.getList.mockResolvedValue([user]); await sut.handleMigration(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - '/original/path.jpg', - expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`), + asset.originalPath, + expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should migrate live photo motion video alongside the still image', async () => { - const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const motionAsset = AssetFactory.from({ + type: AssetType.Video, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const stillAsset = AssetFactory.from({ + livePhotoVideoId: motionAsset.id, + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + }) + .exif() + .build(); + const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`; const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset])); + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); - mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); + mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => { describe('file rename correctness', () => { it('should not create double extensions when filename has lower extension', async () => { - const user = factory.userAdmin({ storageLabel: 'label-1' }); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create({ storageLabel: 'label-1' }); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, originalFileName: 'IMG_7065.HEIC', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => { }); it('should not create double extensions when filename has uppercase extension', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, originalFileName: 'IMG_7065.HEIC', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif({ fileSizeInByte: 12_345 }) + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => { }); it('should normalize the filename to lowercase (JPEG > jpg)', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, originalFileName: 'IMG_7065.JPEG', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', @@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => { }); it('should normalize the filename to lowercase (JPG > jpg)', async () => { - const user = factory.userAdmin(); - const asset = assetStub.storageAsset({ - ownerId: user.id, + const user = UserFactory.create(); + const asset = AssetFactory.from({ + fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG', originalFileName: 'IMG_7065.JPG', - }); - mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); + }) + .owner(user) + .exif() + .build(); + + mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)])); mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts new file mode 100644 index 0000000000..899b529766 --- /dev/null +++ b/server/test/factories/asset-face.factory.ts @@ -0,0 +1,47 @@ +import { Selectable } from 'kysely'; +import { SourceType } from 'src/enum'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { build } from 'test/factories/builder.factory'; +import { PersonFactory } from 'test/factories/person.factory'; +import { AssetFaceLike, FactoryBuilder, PersonLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFaceFactory { + #person: PersonFactory | null = null; + + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetFaceLike = {}) { + return AssetFaceFactory.from(dto).build(); + } + + static from(dto: AssetFaceLike = {}) { + return new AssetFaceFactory({ + assetId: newUuid(), + boundingBoxX1: 11, + boundingBoxX2: 12, + boundingBoxY1: 21, + boundingBoxY2: 22, + deletedAt: null, + id: newUuid(), + imageHeight: 42, + imageWidth: 420, + isVisible: true, + personId: null, + sourceType: SourceType.MachineLearning, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + person(dto: PersonLike = {}, builder?: FactoryBuilder) { + this.#person = build(PersonFactory.from(dto), builder); + this.value.personId = this.#person.build().id; + return this; + } + + build() { + return { ...this.value, person: this.#person?.build() ?? null }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index f8ed7dffc7..258e2aff38 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -1,12 +1,23 @@ import { Selectable } from 'kysely'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { StackTable } from 'src/schema/tables/stack.table'; import { AssetEditFactory } from 'test/factories/asset-edit.factory'; import { AssetExifFactory } from 'test/factories/asset-exif.factory'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { build } from 'test/factories/builder.factory'; -import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { StackFactory } from 'test/factories/stack.factory'; +import { + AssetEditLike, + AssetExifLike, + AssetFaceLike, + AssetFileLike, + AssetLike, + FactoryBuilder, + StackLike, + UserLike, +} from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; @@ -15,7 +26,8 @@ export class AssetFactory { #assetExif?: AssetExifFactory; #files: AssetFileFactory[] = []; #edits: AssetEditFactory[] = []; - #faces: Selectable[] = []; + #faces: AssetFaceFactory[] = []; + #stack?: Selectable & { assets: Selectable[]; primaryAsset: Selectable }; private constructor(private readonly value: Selectable) { value.ownerId ??= newUuid(); @@ -83,8 +95,8 @@ export class AssetFactory { return this; } - face(dto: Selectable) { - this.#faces.push(dto); + face(dto: AssetFaceLike = {}, builder?: FactoryBuilder) { + this.#faces.push(build(AssetFaceFactory.from(dto), builder)); return this; } @@ -117,6 +129,12 @@ export class AssetFactory { return this; } + stack(dto: StackLike = {}, builder?: FactoryBuilder) { + this.#stack = build(StackFactory.from(dto).primaryAsset(this.value), builder).build(); + this.value.stackId = this.#stack.id; + return this; + } + build() { const exif = this.#assetExif?.build(); @@ -126,8 +144,9 @@ export class AssetFactory { exifInfo: exif as NonNullable, files: this.#files.map((file) => file.build()), edits: this.#edits.map((edit) => edit.build()), - faces: this.#faces, - stack: null, + faces: this.#faces.map((face) => face.build()), + stack: this.#stack ?? null, + tags: [], }; } } diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts new file mode 100644 index 0000000000..8e016e5398 --- /dev/null +++ b/server/test/factories/person.factory.ts @@ -0,0 +1,34 @@ +import { Selectable } from 'kysely'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { PersonLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PersonFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: PersonLike = {}) { + return PersonFactory.from(dto).build(); + } + + static from(dto: PersonLike = {}) { + return new PersonFactory({ + birthDate: null, + color: null, + createdAt: newDate(), + faceAssetId: null, + id: newUuid(), + isFavorite: false, + isHidden: false, + name: 'person', + ownerId: newUuid(), + thumbnailPath: '/data/thumbs/person-thumbnail.jpg', + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/stack.factory.ts b/server/test/factories/stack.factory.ts new file mode 100644 index 0000000000..69775973c4 --- /dev/null +++ b/server/test/factories/stack.factory.ts @@ -0,0 +1,52 @@ +import { Selectable } from 'kysely'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetLike, FactoryBuilder, StackLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class StackFactory { + #assets: AssetFactory[] = []; + #primaryAsset: AssetFactory; + + private constructor(private readonly value: Selectable) { + this.#primaryAsset = AssetFactory.from(); + this.value.primaryAssetId = this.#primaryAsset.build().id; + } + + static create(dto: StackLike = {}) { + return StackFactory.from(dto).build(); + } + + static from(dto: StackLike = {}) { + return new StackFactory({ + createdAt: newDate(), + id: newUuid(), + ownerId: newUuid(), + primaryAssetId: newUuid(), + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + this.#assets.push(build(AssetFactory.from(dto), builder)); + return this; + } + + primaryAsset(dto: AssetLike = {}, builder?: FactoryBuilder) { + this.#primaryAsset = build(AssetFactory.from(dto), builder); + this.value.primaryAssetId = this.#primaryAsset.build().id; + this.#assets.push(this.#primaryAsset); + return this; + } + + build() { + return { + ...this.value, + assets: this.#assets.map((asset) => asset.build()), + primaryAsset: this.#primaryAsset.build(), + }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 534e290f59..c5a327a624 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -3,9 +3,12 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { PersonTable } from 'src/schema/tables/person.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; export type FactoryBuilder = (builder: T) => R; @@ -18,3 +21,6 @@ export type AlbumLike = Partial>; export type AlbumUserLike = Partial>; export type SharedLinkLike = Partial>; export type UserLike = Partial>; +export type AssetFaceLike = Partial>; +export type PersonLike = Partial>; +export type StackLike = Partial>; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts deleted file mode 100644 index e4b2a168eb..0000000000 --- a/server/test/fixtures/asset.stub.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { Exif } from 'src/database'; -import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { StorageAsset } from 'src/types'; -import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; - -export const previewFile = factory.assetFile({ type: AssetFileType.Preview }); - -const thumbnailFile = factory.assetFile({ - type: AssetFileType.Thumbnail, - path: '/uploads/user-id/webp/path.ext', -}); - -const fullsizeFile = factory.assetFile({ - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/path.webp', -}); - -const files = [fullsizeFile, previewFile, thumbnailFile]; - -export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { - return { - id: stackId, - assets, - ownerId: assets[0].ownerId, - primaryAsset: assets[0], - primaryAssetId: assets[0].id, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - updateId: expect.any(String), - }; -}; - -export const assetStub = { - storageAsset: (asset: Partial = {}) => ({ - id: 'asset-id', - ownerId: 'user-id', - livePhotoVideoId: null, - type: AssetType.Image, - isExternal: false, - checksum: Buffer.from('file hash'), - timeZone: null, - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - originalPath: '/original/path.jpg', - originalFileName: 'IMG_123.jpg', - fileSizeInByte: 12_345, - files: [], - make: 'FUJIFILM', - model: 'X-T50', - lensModel: 'XF27mm F2.8 R WR', - isEdited: false, - ...asset, - }), - - primaryImage: Object.freeze({ - id: 'primary-asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.admin, - ownerId: 'admin-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - files, - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 1000, - exifImageWidth: 1000, - } as Exif, - stackId: 'stack-1', - stack: stackStub('stack-1', [ - { id: 'primary-asset-id' } as MapAsset & { exifInfo: Exif }, - { id: 'stack-child-asset-1' } as MapAsset & { exifInfo: Exif }, - { id: 'stack-child-asset-2' } as MapAsset & { exifInfo: Exif }, - ]), - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - withLocation: Object.freeze({ - id: 'asset-with-favorite-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - checksum: Buffer.from('file hash', 'utf8'), - originalPath: '/original/path.ext', - type: AssetType.Image, - files: [previewFile], - thumbhash: null, - encodedVideoPath: null, - createdAt: new Date('2023-02-22T05:06:29.716Z'), - updatedAt: new Date('2023-02-22T05:06:29.716Z'), - localDateTime: new Date('2020-12-31T23:59:00.000Z'), - isFavorite: false, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - latitude: 100, - longitude: 100, - fileSizeInByte: 23_456, - city: 'test-city', - state: 'test-state', - country: 'test-country', - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - tags: [], - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - hasEncodedVideo: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - originalFileName: 'asset-id.ext', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Video, - files: [previewFile], - thumbhash: null, - encodedVideoPath: '/encoded/video/path.mp4', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - faces: [], - exifInfo: { - fileSizeInByte: 100_000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - stack: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - imageDng: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.dng', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.dng', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - profileDescription: 'Adobe RGB', - bitsPerSample: 14, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - withCropEdit: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: 3840, - width: 2160, - visibility: AssetVisibility.Timeline, - edits: [ - { - action: AssetEditAction.Crop, - parameters: { - width: 1512, - height: 1152, - x: 216, - y: 1512, - }, - }, - ] as AssetEditActionItem[], - isEdited: true, - }), -}; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 35a7a8ed7d..9d48fcc8f8 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -1,5 +1,5 @@ -import { AssetType } from 'src/enum'; -import { previewFile } from 'test/fixtures/asset.stub'; +import { AssetFileType, AssetType } from 'src/enum'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { userStub } from 'test/fixtures/user.stub'; const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125'; @@ -179,7 +179,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), newThumbnailMiddle: Object.freeze({ ownerId: userStub.admin.id, @@ -192,7 +192,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), newThumbnailEnd: Object.freeze({ ownerId: userStub.admin.id, @@ -205,7 +205,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), rawEmbeddedThumbnail: Object.freeze({ ownerId: userStub.admin.id, @@ -218,7 +218,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.dng', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), negativeCoordinate: Object.freeze({ ownerId: userStub.admin.id, @@ -231,7 +231,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), overflowingCoordinate: Object.freeze({ ownerId: userStub.admin.id, @@ -244,7 +244,7 @@ export const personThumbnailStub = { type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), videoThumbnail: Object.freeze({ ownerId: userStub.admin.id, @@ -257,6 +257,6 @@ export const personThumbnailStub = { type: AssetType.Video, originalPath: '/original/path.mp4', exifOrientation: '1', - previewPath: previewFile.path, + previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path, }), }; diff --git a/server/test/mappers.ts b/server/test/mappers.ts new file mode 100644 index 0000000000..89ca79d864 --- /dev/null +++ b/server/test/mappers.ts @@ -0,0 +1,22 @@ +import { AssetFactory } from 'test/factories/asset.factory'; + +export const getForStorageTemplate = (asset: ReturnType) => { + return { + id: asset.id, + ownerId: asset.ownerId, + livePhotoVideoId: asset.livePhotoVideoId, + type: asset.type, + isExternal: asset.isExternal, + checksum: asset.checksum, + timeZone: asset.exifInfo.timeZone, + fileCreatedAt: asset.fileCreatedAt, + originalPath: asset.originalPath, + originalFileName: asset.originalFileName, + fileSizeInByte: asset.exifInfo.fileSizeInByte, + files: asset.files, + make: asset.exifInfo.make, + model: asset.exifInfo.model, + lensModel: asset.exifInfo.lensModel, + isEdited: asset.isEdited, + }; +};