2025-02-10 18:47:42 -05:00
|
|
|
import { JobName, JobStatus } from 'src/interfaces/job.interface';
|
2025-02-11 14:08:13 -05:00
|
|
|
import { WithoutProperty } from 'src/repositories/asset.repository';
|
2024-05-16 19:39:33 -04:00
|
|
|
import { DuplicateService } from 'src/services/duplicate.service';
|
|
|
|
|
import { SearchService } from 'src/services/search.service';
|
|
|
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
2024-10-08 23:08:49 +02:00
|
|
|
import { authStub } from 'test/fixtures/auth.stub';
|
2025-02-10 18:47:42 -05:00
|
|
|
import { newTestService, ServiceMocks } from 'test/utils';
|
|
|
|
|
import { beforeEach, vitest } from 'vitest';
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
vitest.useFakeTimers();
|
|
|
|
|
|
|
|
|
|
describe(SearchService.name, () => {
|
|
|
|
|
let sut: DuplicateService;
|
2025-02-10 18:47:42 -05:00
|
|
|
let mocks: ServiceMocks;
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2025-02-10 18:47:42 -05:00
|
|
|
({ sut, mocks } = newTestService(DuplicateService));
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should work', () => {
|
|
|
|
|
expect(sut).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
2024-10-08 23:08:49 +02:00
|
|
|
describe('getDuplicates', () => {
|
|
|
|
|
it('should get duplicates', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getDuplicates.mockResolvedValue([
|
2025-01-09 11:15:41 -05:00
|
|
|
{
|
|
|
|
|
duplicateId: assetStub.hasDupe.duplicateId!,
|
|
|
|
|
assets: [assetStub.hasDupe, assetStub.hasDupe],
|
|
|
|
|
},
|
|
|
|
|
]);
|
2024-10-08 23:08:49 +02:00
|
|
|
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
2024-11-04 20:33:03 +05:30
|
|
|
{
|
|
|
|
|
duplicateId: assetStub.hasDupe.duplicateId,
|
|
|
|
|
assets: [
|
|
|
|
|
expect.objectContaining({ id: assetStub.hasDupe.id }),
|
|
|
|
|
expect.objectContaining({ id: assetStub.hasDupe.id }),
|
|
|
|
|
],
|
|
|
|
|
},
|
2024-10-08 23:08:49 +02:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2024-05-16 19:39:33 -04:00
|
|
|
describe('handleQueueSearchDuplicates', () => {
|
|
|
|
|
beforeEach(() => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip if machine learning is disabled', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
|
|
|
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
|
|
|
|
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip if duplicate detection is disabled', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
|
|
|
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
|
|
|
|
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should queue missing assets', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getWithout.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
items: [assetStub.image],
|
|
|
|
|
hasNextPage: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await sut.handleQueueSearchDuplicates({});
|
|
|
|
|
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE);
|
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
2024-05-16 19:39:33 -04:00
|
|
|
{
|
|
|
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
|
|
|
data: { id: assetStub.image.id },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should queue all assets', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getAll.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
items: [assetStub.image],
|
|
|
|
|
hasNextPage: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await sut.handleQueueSearchDuplicates({ force: true });
|
|
|
|
|
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.getAll).toHaveBeenCalled();
|
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
2024-05-16 19:39:33 -04:00
|
|
|
{
|
|
|
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
|
|
|
data: { id: assetStub.image.id },
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('handleSearchDuplicates', () => {
|
|
|
|
|
beforeEach(() => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip if machine learning is disabled', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip if duplicate detection is disabled', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
2024-05-16 19:39:33 -04:00
|
|
|
machineLearning: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
duplicateDetection: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
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);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`);
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip if asset is not visible', async () => {
|
|
|
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`);
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should fail if asset is missing preview image', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.noResizePath);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.logger.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`);
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should fail if asset is missing embedding', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`);
|
2024-05-16 19:39:33 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should search for duplicates and update asset with duplicateId', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding);
|
|
|
|
|
mocks.search.searchDuplicates.mockResolvedValue([
|
2024-05-16 19:39:33 -04:00
|
|
|
{ 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);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({
|
2024-05-16 19:39:33 -04:00
|
|
|
assetId: assetStub.hasEmbedding.id,
|
|
|
|
|
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
|
2024-06-30 17:36:02 +02:00
|
|
|
maxDistance: 0.01,
|
2024-05-26 18:04:23 -04:00
|
|
|
type: assetStub.hasEmbedding.type,
|
2024-05-16 19:39:33 -04:00
|
|
|
userIds: [assetStub.hasEmbedding.ownerId],
|
|
|
|
|
});
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({
|
2024-05-16 19:39:33 -04:00
|
|
|
assetIds: expectedAssetIds,
|
|
|
|
|
targetDuplicateId: expect.any(String),
|
|
|
|
|
duplicateIds: [],
|
|
|
|
|
});
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith(
|
2024-05-16 19:39:33 -04:00
|
|
|
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should use existing duplicate ID among matched duplicates', async () => {
|
|
|
|
|
const duplicateId = assetStub.hasDupe.duplicateId;
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding);
|
|
|
|
|
mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]);
|
2024-05-16 19:39:33 -04:00
|
|
|
const expectedAssetIds = [assetStub.hasEmbedding.id];
|
|
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({
|
2024-05-16 19:39:33 -04:00
|
|
|
assetId: assetStub.hasEmbedding.id,
|
|
|
|
|
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
|
2024-06-30 17:36:02 +02:00
|
|
|
maxDistance: 0.01,
|
2024-05-26 18:04:23 -04:00
|
|
|
type: assetStub.hasEmbedding.type,
|
2024-05-16 19:39:33 -04:00
|
|
|
userIds: [assetStub.hasEmbedding.ownerId],
|
|
|
|
|
});
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({
|
2024-05-16 19:39:33 -04:00
|
|
|
assetIds: expectedAssetIds,
|
|
|
|
|
targetDuplicateId: assetStub.hasDupe.duplicateId,
|
|
|
|
|
duplicateIds: [],
|
|
|
|
|
});
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith(
|
2024-05-16 19:39:33 -04:00
|
|
|
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => {
|
2025-02-10 18:47:42 -05:00
|
|
|
mocks.asset.getById.mockResolvedValue(assetStub.hasDupe);
|
|
|
|
|
mocks.search.searchDuplicates.mockResolvedValue([]);
|
2024-05-16 19:39:33 -04:00
|
|
|
|
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
2025-02-10 18:47:42 -05:00
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null });
|
|
|
|
|
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({
|
2024-05-16 19:39:33 -04:00
|
|
|
assetId: assetStub.hasDupe.id,
|
|
|
|
|
duplicatesDetectedAt: expect.any(Date),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|