2024-03-20 23:53:07 +01:00
|
|
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
2024-05-16 19:08:37 +02:00
|
|
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
|
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|
|
|
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
2024-04-16 23:30:31 +02:00
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
|
|
|
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
|
|
|
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
|
|
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
|
|
|
import { ISearchRepository } from 'src/interfaces/search.interface';
|
2024-05-16 00:58:23 +02:00
|
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
2024-03-21 00:07:30 +01:00
|
|
|
import { SearchService } from 'src/services/search.service';
|
2024-03-20 19:32:04 +01:00
|
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
|
|
|
import { authStub } from 'test/fixtures/auth.stub';
|
|
|
|
import { personStub } from 'test/fixtures/person.stub';
|
|
|
|
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
2024-05-16 19:08:37 +02:00
|
|
|
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
|
|
|
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
2024-04-16 23:30:31 +02:00
|
|
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
2024-03-20 19:32:04 +01:00
|
|
|
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
|
|
|
|
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
|
|
|
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
|
|
|
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
|
|
|
|
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
|
2024-05-16 00:58:23 +02:00
|
|
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
2024-05-16 19:08:37 +02:00
|
|
|
import { Mocked, beforeEach, vitest } from 'vitest';
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2024-04-16 16:44:45 +02:00
|
|
|
vitest.useFakeTimers();
|
2023-03-18 14:44:42 +01:00
|
|
|
|
2023-03-03 03:47:08 +01:00
|
|
|
describe(SearchService.name, () => {
|
|
|
|
let sut: SearchService;
|
2024-04-16 16:44:45 +02:00
|
|
|
let assetMock: Mocked<IAssetRepository>;
|
2024-05-16 00:58:23 +02:00
|
|
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
2024-04-16 16:44:45 +02:00
|
|
|
let machineMock: Mocked<IMachineLearningRepository>;
|
|
|
|
let personMock: Mocked<IPersonRepository>;
|
|
|
|
let searchMock: Mocked<ISearchRepository>;
|
|
|
|
let partnerMock: Mocked<IPartnerRepository>;
|
|
|
|
let metadataMock: Mocked<IMetadataRepository>;
|
2024-04-16 23:30:31 +02:00
|
|
|
let loggerMock: Mocked<ILoggerRepository>;
|
2024-05-16 19:08:37 +02:00
|
|
|
let cryptoMock: Mocked<ICryptoRepository>;
|
|
|
|
let jobMock: Mocked<IJobRepository>;
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
beforeEach(() => {
|
2023-03-03 03:47:08 +01:00
|
|
|
assetMock = newAssetRepositoryMock();
|
2024-05-16 00:58:23 +02:00
|
|
|
systemMock = newSystemMetadataRepositoryMock();
|
2023-03-18 14:44:42 +01:00
|
|
|
machineMock = newMachineLearningRepositoryMock();
|
2023-12-08 17:15:46 +01:00
|
|
|
personMock = newPersonRepositoryMock();
|
2024-02-13 02:50:47 +01:00
|
|
|
searchMock = newSearchRepositoryMock();
|
2024-01-01 23:25:22 +01:00
|
|
|
partnerMock = newPartnerRepositoryMock();
|
2024-02-13 20:54:58 +01:00
|
|
|
metadataMock = newMetadataRepositoryMock();
|
2024-04-16 23:30:31 +02:00
|
|
|
loggerMock = newLoggerRepositoryMock();
|
2024-05-16 19:08:37 +02:00
|
|
|
cryptoMock = newCryptoRepositoryMock();
|
|
|
|
jobMock = newJobRepositoryMock();
|
2024-02-13 20:54:58 +01:00
|
|
|
|
2024-04-16 23:30:31 +02:00
|
|
|
sut = new SearchService(
|
2024-05-16 00:58:23 +02:00
|
|
|
systemMock,
|
2024-04-16 23:30:31 +02:00
|
|
|
machineMock,
|
|
|
|
personMock,
|
|
|
|
searchMock,
|
|
|
|
assetMock,
|
|
|
|
partnerMock,
|
|
|
|
metadataMock,
|
|
|
|
loggerMock,
|
2024-05-16 19:08:37 +02:00
|
|
|
cryptoMock,
|
|
|
|
jobMock,
|
2024-04-16 23:30:31 +02:00
|
|
|
);
|
2023-03-03 03:47:08 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
it('should work', () => {
|
|
|
|
expect(sut).toBeDefined();
|
|
|
|
});
|
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
describe('searchPerson', () => {
|
|
|
|
it('should pass options to search', async () => {
|
|
|
|
const { name } = personStub.withName;
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
await sut.searchPerson(authStub.user1, { name, withHidden: false });
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
await sut.searchPerson(authStub.user1, { name, withHidden: true });
|
2023-03-03 03:47:08 +01:00
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true });
|
2023-03-03 03:47:08 +01:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
2023-09-11 17:56:38 +02:00
|
|
|
describe('getExploreData', () => {
|
2023-12-08 17:15:46 +01:00
|
|
|
it('should get assets by city and tag', async () => {
|
2024-05-16 19:08:37 +02:00
|
|
|
assetMock.getAssetIdByCity.mockResolvedValue({
|
2023-12-08 17:15:46 +01:00
|
|
|
fieldName: 'exifInfo.city',
|
|
|
|
items: [{ value: 'Paris', data: assetStub.image.id }],
|
|
|
|
});
|
2024-05-16 19:08:37 +02:00
|
|
|
assetMock.getAssetIdByTag.mockResolvedValue({
|
2023-12-08 17:15:46 +01:00
|
|
|
fieldName: 'smartInfo.tags',
|
|
|
|
items: [{ value: 'train', data: assetStub.imageFrom2015.id }],
|
|
|
|
});
|
2024-05-16 19:08:37 +02:00
|
|
|
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
|
2023-12-08 17:15:46 +01:00
|
|
|
const expectedResponse = [
|
|
|
|
{ fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },
|
|
|
|
{ fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] },
|
|
|
|
];
|
2023-09-11 17:56:38 +02:00
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
const result = await sut.getExploreData(authStub.user1);
|
2023-09-11 17:56:38 +02:00
|
|
|
|
2023-12-08 17:15:46 +01:00
|
|
|
expect(result).toEqual(expectedResponse);
|
2023-09-11 17:56:38 +02:00
|
|
|
});
|
|
|
|
});
|
2024-05-16 19:08:37 +02:00
|
|
|
|
|
|
|
describe('handleQueueSearchDuplicates', () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: true,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip if machine learning is disabled', async () => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: false,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
|
|
|
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
|
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
|
|
|
expect(systemMock.get).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip if duplicate detection is disabled', async () => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: true,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: false,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
|
|
|
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
|
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
|
|
|
expect(systemMock.get).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should queue missing assets', async () => {
|
|
|
|
assetMock.getWithout.mockResolvedValue({
|
|
|
|
items: [assetStub.image],
|
|
|
|
hasNextPage: false,
|
|
|
|
});
|
|
|
|
|
|
|
|
await sut.handleQueueSearchDuplicates({});
|
|
|
|
|
|
|
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE);
|
|
|
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
|
|
|
{
|
|
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
|
|
data: { id: assetStub.image.id },
|
|
|
|
},
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should queue all assets', async () => {
|
|
|
|
assetMock.getAll.mockResolvedValue({
|
|
|
|
items: [assetStub.image],
|
|
|
|
hasNextPage: false,
|
|
|
|
});
|
|
|
|
personMock.getAll.mockResolvedValue({
|
|
|
|
items: [personStub.withName],
|
|
|
|
hasNextPage: false,
|
|
|
|
});
|
|
|
|
|
|
|
|
await sut.handleQueueSearchDuplicates({ force: true });
|
|
|
|
|
|
|
|
expect(assetMock.getAll).toHaveBeenCalled();
|
|
|
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
|
|
|
{
|
|
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
|
|
data: { id: assetStub.image.id },
|
|
|
|
},
|
|
|
|
]);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('handleSearchDuplicates', () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: true,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip if machine learning is disabled', async () => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: false,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: true,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip if duplicate detection is disabled', async () => {
|
|
|
|
systemMock.get.mockResolvedValue({
|
|
|
|
machineLearning: {
|
|
|
|
enabled: true,
|
|
|
|
duplicateDetection: {
|
|
|
|
enabled: false,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should fail if asset is not found', async () => {
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
|
|
|
expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should skip if asset is not visible', async () => {
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
|
|
expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should fail if asset is missing preview image', async () => {
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.noResizePath);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
|
|
|
expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should fail if asset is missing embedding', async () => {
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.image);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
|
|
|
expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should search for duplicates and update asset with duplicateId', async () => {
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.hasEmbedding);
|
|
|
|
searchMock.searchDuplicates.mockResolvedValue([
|
|
|
|
{ assetId: assetStub.image.id, distance: 0.01, duplicateId: null },
|
|
|
|
]);
|
|
|
|
const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id];
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
|
|
|
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
|
|
|
|
assetId: assetStub.hasEmbedding.id,
|
|
|
|
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
|
|
|
|
maxDistance: 0.03,
|
|
|
|
userIds: [assetStub.hasEmbedding.ownerId],
|
|
|
|
});
|
|
|
|
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
|
|
|
|
assetIds: expectedAssetIds,
|
|
|
|
targetDuplicateId: expect.any(String),
|
|
|
|
duplicateIds: [],
|
|
|
|
});
|
|
|
|
expect(assetMock.upsertJobStatus).toHaveBeenCalledWith(
|
|
|
|
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should use existing duplicate ID among matched duplicates', async () => {
|
|
|
|
const duplicateId = assetStub.hasDupe.duplicateId;
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.hasEmbedding);
|
|
|
|
searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]);
|
|
|
|
const expectedAssetIds = [assetStub.hasEmbedding.id];
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
|
|
|
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
|
|
|
|
assetId: assetStub.hasEmbedding.id,
|
|
|
|
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
|
|
|
|
maxDistance: 0.03,
|
|
|
|
userIds: [assetStub.hasEmbedding.ownerId],
|
|
|
|
});
|
|
|
|
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
|
|
|
|
assetIds: expectedAssetIds,
|
|
|
|
targetDuplicateId: assetStub.hasDupe.duplicateId,
|
|
|
|
duplicateIds: [],
|
|
|
|
});
|
|
|
|
expect(assetMock.upsertJobStatus).toHaveBeenCalledWith(
|
|
|
|
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => {
|
|
|
|
assetMock.getById.mockResolvedValue(assetStub.hasDupe);
|
|
|
|
searchMock.searchDuplicates.mockResolvedValue([]);
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id });
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
|
|
|
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null });
|
|
|
|
expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({
|
|
|
|
assetId: assetStub.hasDupe.id,
|
|
|
|
duplicatesDetectedAt: expect.any(Date),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2023-03-03 03:47:08 +01:00
|
|
|
});
|