1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

chore(server): unit tests for metadata service (#4280)

* unit tests for metadata service

* better test descriptions
This commit is contained in:
Daniel Dietzler 2023-09-29 23:25:45 +02:00 committed by GitHub
parent 68d6d89a3b
commit 10d10d9021
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 343 additions and 14 deletions

View File

@ -148,7 +148,7 @@
"coverageDirectory": "./coverage",
"coverageThreshold": {
"./src/domain/": {
"branches": 75,
"branches": 80,
"functions": 80,
"lines": 90,
"statements": 90

View File

@ -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 }));
});
});
});

View File

@ -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) {