From 10d10d9021229d4ffb18b00fa119f898c966fcd6 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 29 Sep 2023 23:25:45 +0200 Subject: [PATCH] chore(server): unit tests for metadata service (#4280) * unit tests for metadata service * better test descriptions --- server/package.json | 2 +- .../domain/metadata/metadata.service.spec.ts | 353 +++++++++++++++++- .../src/domain/metadata/metadata.service.ts | 2 +- 3 files changed, 343 insertions(+), 14 deletions(-) diff --git a/server/package.json b/server/package.json index a7f05577f9..b27a0b9e71 100644 --- a/server/package.json +++ b/server/package.json @@ -148,7 +148,7 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/domain/": { - "branches": 75, + "branches": 80, "functions": 80, "lines": 90, "statements": 90 diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 96a1c91c03..e5613c2852 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -1,3 +1,4 @@ +import { AssetType, CitiesFile, ExifEntity, SystemConfigKey } from '@app/infra/entities'; import { assetStub, newAlbumRepositoryMock, @@ -8,14 +9,16 @@ import { newStorageRepositoryMock, newSystemConfigRepositoryMock, } from '@test'; +import { randomBytes } from 'crypto'; +import { Stats } from 'fs'; import { constants } from 'fs/promises'; import { IAlbumRepository } from '../album'; import { IAssetRepository, WithProperty, WithoutProperty } from '../asset'; import { ICryptoRepository } from '../crypto'; -import { IJobRepository, JobName } from '../job'; +import { IJobRepository, JobName, QueueName } from '../job'; import { IStorageRepository } from '../storage'; import { ISystemConfigRepository } from '../system-config'; -import { IMetadataRepository } from './metadata.repository'; +import { IMetadataRepository, ImmichTags } from './metadata.repository'; import { MetadataService } from './metadata.service'; describe(MetadataService.name, () => { @@ -44,6 +47,342 @@ describe(MetadataService.name, () => { expect(sut).toBeDefined(); }); + describe('init', () => { + beforeEach(async () => { + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }, + { key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_500 }, + ]); + + await sut.init(); + }); + + it('should return if reverse geocoding is disabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); + + await sut.init(); + expect(metadataMock.deleteCache).not.toHaveBeenCalled(); + expect(jobMock.pause).toHaveBeenCalledTimes(1); + expect(metadataMock.init).toHaveBeenCalledTimes(1); + expect(jobMock.resume).toHaveBeenCalledTimes(1); + }); + + it('should return if deleteCache is false and the cities precision has not changed', async () => { + await sut.init(); + + expect(metadataMock.deleteCache).not.toHaveBeenCalled(); + expect(jobMock.pause).toHaveBeenCalledTimes(1); + expect(metadataMock.init).toHaveBeenCalledTimes(1); + expect(jobMock.resume).toHaveBeenCalledTimes(1); + }); + + it('should re-init if deleteCache is false but the cities precision has changed', async () => { + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_1000 }, + ]); + + await sut.init(); + + expect(metadataMock.deleteCache).not.toHaveBeenCalled(); + expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_1000 }); + expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + }); + + it('should re-init and delete cache if deleteCache is true', async () => { + await sut.init(true); + + expect(metadataMock.deleteCache).toHaveBeenCalled(); + expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_500 }); + expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + }); + }); + + describe('handleLivePhotoLinking', () => { + it('should handle an asset that could not be found', async () => { + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalled(); + expect(albumMock.removeAsset).not.toHaveBeenCalled(); + }); + + it('should handle an asset without exif info', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalled(); + expect(albumMock.removeAsset).not.toHaveBeenCalled(); + }); + + it('should handle livePhotoCID not set', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(true); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalled(); + expect(albumMock.removeAsset).not.toHaveBeenCalled(); + }); + + it('should handle not finding a match', async () => { + assetMock.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoMotionAsset, + exifInfo: { livePhotoCID: assetStub.livePhotoStillAsset.id } as ExifEntity, + }, + ]); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(true); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + livePhotoCID: assetStub.livePhotoStillAsset.id, + ownerId: assetStub.livePhotoMotionAsset.ownerId, + otherAssetId: assetStub.livePhotoMotionAsset.id, + type: AssetType.IMAGE, + }); + expect(assetMock.save).not.toHaveBeenCalled(); + expect(albumMock.removeAsset).not.toHaveBeenCalled(); + }); + + it('should link photo and video', async () => { + assetMock.getByIds.mockResolvedValue([ + { + ...assetStub.livePhotoStillAsset, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + }, + ]); + assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); + + await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); + expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ + livePhotoCID: assetStub.livePhotoMotionAsset.id, + ownerId: assetStub.livePhotoStillAsset.ownerId, + otherAssetId: assetStub.livePhotoStillAsset.id, + type: AssetType.VIDEO, + }); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + }); + }); + + describe('handleQueueMetadataExtraction', () => { + it('should queue metadata extraction for all assets without exif values', async () => { + assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + + await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(true); + expect(assetMock.getWithout).toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.METADATA_EXTRACTION, + data: { id: assetStub.image.id }, + }); + }); + + it('should queue metadata extraction for all assets', async () => { + assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); + + await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(true); + expect(assetMock.getAll).toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.METADATA_EXTRACTION, + data: { id: assetStub.image.id }, + }); + }); + }); + + describe('handleMetadataExtraction', () => { + beforeEach(() => { + storageMock.stat.mockResolvedValue({ size: 123456 } as Stats); + }); + + it('should handle an asset that could not be found', async () => { + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(false); + + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalled(); + }); + + it('should handle an asset with isVisible set to false', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, isVisible: false }]); + + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(false); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalled(); + }); + + it('should handle lists of numbers', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: assetStub.image.createdAt, + }); + }); + + it('should apply reverse geocoding', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); + metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + metadataMock.getExifTags.mockResolvedValue({ + GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, + GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), + ); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.withLocation.id, + duration: null, + fileCreatedAt: assetStub.withLocation.createdAt, + }); + }); + + it('should not apply motion photos if asset is video', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + + await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalledWith( + expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), + ); + }); + + it('should apply motion photos', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + metadataMock.getExifTags.mockResolvedValue({ + Directory: 'foo/bar/', + MotionPhoto: 1, + MicroVideo: 1, + MicroVideoOffset: 1, + }); + storageMock.readFile.mockResolvedValue(randomBytes(512)); + cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); + + await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); + expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object)); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + }); + + it('should create new motion asset if not found and link it with the photo', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + metadataMock.getExifTags.mockResolvedValue({ + Directory: 'foo/bar/', + MotionPhoto: 1, + MicroVideo: 1, + MicroVideoOffset: 1, + }); + const video = randomBytes(512); + storageMock.readFile.mockResolvedValue(video); + cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + assetMock.save.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + + await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); + expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object)); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(assetMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + type: AssetType.VIDEO, + originalFileName: assetStub.livePhotoStillAsset.originalFileName, + isVisible: false, + isReadOnly: true, + }), + ); + expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.METADATA_EXTRACTION, + data: { id: assetStub.livePhotoMotionAsset.id }, + }); + }); + + it('should save all metadata', async () => { + const tags: ImmichTags = { + BitsPerSample: 1, + ComponentBitDepth: 1, + ImagePixelDepth: '1', + BitDepth: 1, + ColorBitDepth: 1, + ColorSpace: '1', + DateTimeOriginal: new Date('1970-01-01').toISOString(), + ExposureTime: '100ms', + FocalLength: 20, + ISO: 100, + LensModel: 'test lens', + MediaGroupUUID: 'livePhoto', + Make: 'test-factory', + Model: "'mockel'", + ModifyDate: new Date('1970-01-01').toISOString(), + Orientation: 0, + ProfileDescription: 'extensive description', + ProjectionType: 'equirectangular', + tz: '+02:00', + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.getExifTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith({ + assetId: assetStub.image.id, + bitsPerSample: expect.any(Number), + colorspace: tags.ColorSpace, + dateTimeOriginal: new Date('1970-01-01'), + exifImageHeight: null, + exifImageWidth: null, + exposureTime: tags.ExposureTime, + fNumber: null, + fileSizeInByte: 123456, + focalLength: tags.FocalLength, + fps: null, + iso: tags.ISO, + latitude: null, + lensModel: tags.LensModel, + livePhotoCID: tags.MediaGroupUUID, + longitude: null, + make: tags.Make, + model: tags.Model, + modifyDate: expect.any(Date), + orientation: tags.Orientation?.toString(), + profileDescription: tags.ProfileDescription, + projectionType: 'EQUIRECTANGULAR', + timeZone: tags.tz, + }); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: new Date('1970-01-01'), + }); + }); + }); + describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { assetMock.getWith.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); @@ -122,14 +461,4 @@ describe(MetadataService.name, () => { }); }); }); - - describe('handleMetadataExtraction', () => { - it('should handle lists of numbers', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image1]); - storageMock.stat.mockResolvedValue({ size: 123456 } as any); - metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any }); - await sut.handleMetadataExtraction({ id: assetStub.image1.id }); - expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); - }); - }); }); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 297af82ea8..3475169f05 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -264,7 +264,7 @@ export class MetadataService { position, length, }); - const checksum = await this.cryptoRepository.hashSha1(video); + const checksum = this.cryptoRepository.hashSha1(video); let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum); if (!motionAsset) {