added unit tests

This commit is contained in:
mertalev 2023-12-03 19:21:18 -05:00
parent 4015f47052
commit 436f99c747
No known key found for this signature in database
GPG key ID: 9181CD92C0A1C5E3
7 changed files with 172 additions and 93 deletions

View file

@ -1,4 +1,4 @@
import { SystemConfig } from '@app/infra/entities';
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
assetStub,
@ -18,7 +18,7 @@ import {
JobHandler,
JobItem,
} from '../repositories';
import { SystemConfigCore } from '../system-config/system-config.core';
import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
import { JobCommand, JobName, QueueName } from './job.constants';
import { JobService } from './job.service';
@ -281,6 +281,10 @@ describe(JobService.name, () => {
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } },
jobs: [],
},
{
item: { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'asset-1' } },
jobs: [],
},
{
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
jobs: [
@ -357,5 +361,32 @@ describe(JobService.name, () => {
expect(jobMock.queue).not.toHaveBeenCalled();
});
}
const featureTests: Array<{ queue: QueueName, feature: FeatureFlag, configKey: SystemConfigKey}> = [
{
queue: QueueName.CLIP_ENCODING,
feature: FeatureFlag.CLIP_ENCODE,
configKey: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED,
},
{
queue: QueueName.OBJECT_TAGGING,
feature: FeatureFlag.TAG_IMAGE,
configKey: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED,
},
{
queue: QueueName.RECOGNIZE_FACES,
feature: FeatureFlag.FACIAL_RECOGNITION,
configKey: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED,
},
];
for (const { queue, feature, configKey } of featureTests) {
it(`should throw an error if attempting to queue ${queue} when ${feature} is disabled`, async () => {
configMock.load.mockResolvedValue([{ key: configKey, value: false }]);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
expect(sut.handleCommand(queue, { command: JobCommand.START, force: false })).rejects.toThrow();
});
}
});
});

View file

@ -144,7 +144,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
};
}
export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto {
export function mapFace(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto {
return {
id: face.id,
imageHeight: face.imageHeight,

View file

@ -12,7 +12,6 @@ import {
newMediaRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newSearchRepositoryMock,
newSmartInfoRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
@ -27,13 +26,12 @@ import {
IMediaRepository,
IMoveRepository,
IPersonRepository,
ISearchRepository,
ISmartInfoRepository,
IStorageRepository,
ISystemConfigRepository,
WithoutProperty,
} from '../repositories';
import { PersonResponseDto, mapFaces } from './person.dto';
import { PersonResponseDto, mapFace } from './person.dto';
import { PersonService } from './person.service';
const responseDto: PersonResponseDto = {
@ -97,7 +95,7 @@ describe(PersonService.name, () => {
configMock,
storageMock,
jobMock,
smartInfoMock
smartInfoMock,
);
mediaMock.crop.mockResolvedValue(croppedFace);
@ -385,7 +383,7 @@ describe(PersonService.name, () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId]));
personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
mapFaces(faceStub.primaryFace1, authStub.admin),
mapFace(faceStub.primaryFace1, authStub.admin),
]);
});
it('should reject if the user has not access to the asset', async () => {
@ -475,6 +473,17 @@ describe(PersonService.name, () => {
});
});
describe('handlePersonDelete', () => {
it('should delete person', async () => {
personMock.getById.mockResolvedValue(personStub.withName);
await sut.handlePersonDelete({ id: personStub.withName.id });
expect(personMock.delete).toHaveBeenCalledWith(personStub.withName);
expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
@ -836,4 +845,24 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
describe('mapFace', () => {
it('should map a face', () => {
expect(mapFace(faceStub.face1, authStub.user1)).toEqual({
id: 'person-1',
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
});
});
it('should not map person if person is null', () => {
expect(mapFace({ ...faceStub.face1, person: null }, authStub.user1).person).toBeNull();
});
it('should not map person if person does not match auth user id', () => {
expect(mapFace(faceStub.face1, authStub.user1).person).toBeNull();
});
});
});

View file

@ -38,7 +38,7 @@ import {
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
mapFaces,
mapFace,
mapPerson,
} from './person.dto';
@ -138,7 +138,7 @@ export class PersonService {
async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id);
const faces = await this.repository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, authUser));
return faces.map((asset) => mapFace(asset, authUser));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {

View file

@ -1,6 +1,5 @@
import { AssetType } from '@app/infra/entities';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
import { Optional, toBoolean } from '../../domain.util';
export class SearchDto {
@ -18,72 +17,6 @@ export class SearchDto {
@Optional()
@Transform(toBoolean)
clip?: boolean;
@IsEnum(AssetType)
@Optional()
type?: AssetType;
@IsBoolean()
@Optional()
@Transform(toBoolean)
isFavorite?: boolean;
@IsBoolean()
@Optional()
@Transform(toBoolean)
isArchived?: boolean;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.city'?: string;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.state'?: string;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.country'?: string;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.make'?: string;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.model'?: string;
@IsString()
@IsNotEmpty()
@Optional()
'exifInfo.projectionType'?: string;
@IsString({ each: true })
@IsArray()
@Optional()
@Transform(({ value }) => value.split(','))
'smartInfo.objects'?: string[];
@IsString({ each: true })
@IsArray()
@Optional()
@Transform(({ value }) => value.split(','))
'smartInfo.tags'?: string[];
@IsBoolean()
@Optional()
@Transform(toBoolean)
recent?: boolean;
@IsBoolean()
@Optional()
@Transform(toBoolean)
motion?: boolean;
}
export class SearchPeopleDto {

View file

@ -1,11 +1,15 @@
import { mapAsset } from '@app/domain';
import { SystemConfigKey } from '@app/infra/entities';
import {
assetStub,
authStub,
newAssetRepositoryMock,
newMachineLearningRepositoryMock,
newPersonRepositoryMock,
newSmartInfoRepositoryMock,
newSystemConfigRepositoryMock,
personStub,
} from '@test';
import { plainToInstance } from 'class-transformer';
import {
IAssetRepository,
IMachineLearningRepository,
@ -39,25 +43,107 @@ describe(SearchService.name, () => {
expect(sut).toBeDefined();
});
describe('request dto', () => {
it('should convert smartInfo.tags to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.tags': 'a,b,c' });
expect(instance['smartInfo.tags']).toEqual(['a', 'b', 'c']);
describe('searchPerson', () => {
it('should pass options to search', async () => {
const { name } = personStub.withName;
await sut.searchPerson(authStub.user1, { name, withHidden: false });
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.id, name, { withHidden: false });
await sut.searchPerson(authStub.user1, { name, withHidden: true });
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.id, name, { withHidden: true });
});
});
describe('getExploreData', () => {
it('should get assets by city and tag', async () => {
assetMock.getAssetIdByCity.mockResolvedValueOnce({
fieldName: 'exifInfo.city',
items: [{ value: 'Paris', data: assetStub.image.id }],
});
assetMock.getAssetIdByTag.mockResolvedValueOnce({
fieldName: 'smartInfo.tags',
items: [{ value: 'train', data: assetStub.imageFrom2015.id }],
});
assetMock.getByIds.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },
{ fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] },
];
const result = await sut.getExploreData(authStub.user1);
expect(result).toEqual(expectedResponse);
});
});
describe('search', () => {
it('should throw an error if query is missing', async () => {
await expect(sut.search(authStub.user1, { q: '' })).rejects.toThrow('Missing query');
});
it('should handle empty smartInfo.tags', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.tags']).toBeUndefined();
it('should search by metadata if `clip` option is false', async () => {
const dto: SearchDto = { q: 'test query', clip: false };
assetMock.searchMetadata.mockResolvedValueOnce([assetStub.image]);
const expectedResponse = {
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [mapAsset(assetStub.image)],
facets: [],
},
};
const result = await sut.search(authStub.user1, dto);
expect(result).toEqual(expectedResponse);
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, authStub.user1.id, { numResults: 250 });
expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled();
});
it('should convert smartInfo.objects to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.objects': 'a,b,c' });
expect(instance['smartInfo.objects']).toEqual(['a', 'b', 'c']);
it('should search by CLIP if `clip` option is true', async () => {
const dto: SearchDto = { q: 'test query', clip: true };
const embedding = [1, 2, 3];
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
machineMock.encodeText.mockResolvedValueOnce(embedding);
const expectedResponse = {
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [mapAsset(assetStub.image)],
facets: [],
},
};
const result = await sut.search(authStub.user1, dto);
expect(result).toEqual(expectedResponse);
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({ ownerId: authStub.user1.id, embedding, numResults: 100 });
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
});
it('should handle empty smartInfo.objects', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.objects']).toBeUndefined();
it('should throw an error if clip is requested but disabled', async () => {
const dto: SearchDto = { q: 'test query', clip: true };
configMock.load
.mockResolvedValueOnce([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }])
.mockResolvedValueOnce([{ key: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED, value: false }]);
await expect(sut.search(authStub.user1, dto)).rejects.toThrow('CLIP is not enabled');
await expect(sut.search(authStub.user1, dto)).rejects.toThrow('CLIP is not enabled');
});
});
});

View file

@ -32,7 +32,7 @@ export class SearchService {
}
async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return await this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden });
return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden });
}
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetResponseDto>[]> {