Files
immich/server/src/services/duplicate.service.spec.ts
T

528 lines
20 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForDuplicate } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, describe, expect, it, vitest } from 'vitest';
vitest.useFakeTimers();
const hasEmbedding = {
id: 'asset-1',
ownerId: 'user-id',
stackId: null,
type: AssetType.Image,
duplicateId: null,
embedding: '[1, 2, 3, 4]',
visibility: AssetVisibility.Timeline,
};
const hasDupe = {
...hasEmbedding,
id: 'asset-2',
duplicateId: 'duplicate-id',
};
describe(DuplicateService.name, () => {
let sut: DuplicateService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(DuplicateService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getDuplicates', () => {
it('should get duplicates', async () => {
const asset = AssetFactory.from().exif().build();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['duplicate-id']));
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
mocks.duplicateRepository.getAll.mockResolvedValue([
{
duplicateId: 'duplicate-id',
assets: [getForDuplicate(asset), getForDuplicate(asset)],
},
]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
{
duplicateId: 'duplicate-id',
assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })],
suggestedKeepAssetIds: [asset.id],
},
]);
});
it('should return suggestedKeepAssetIds based on file size', async () => {
const smallAsset = AssetFactory.from().exif({ fileSizeInByte: 1000 }).build();
const largeAsset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
mocks.duplicateRepository.getAll.mockResolvedValue([
{
duplicateId: 'duplicate-id',
assets: [getForDuplicate(smallAsset), getForDuplicate(largeAsset)],
},
]);
const result = await sut.getDuplicates(authStub.admin);
expect(result[0].suggestedKeepAssetIds).toEqual([largeAsset.id]);
});
});
describe('handleQueueSearchDuplicates', () => {
beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: true,
duplicateDetection: {
enabled: true,
},
},
});
});
it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: false,
duplicateDetection: {
enabled: true,
},
},
});
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.Skipped);
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should skip if duplicate detection is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: true,
duplicateDetection: {
enabled: false,
},
},
});
await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.Skipped);
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should queue missing assets', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset]));
await sut.handleQueueSearchDuplicates({});
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetDetectDuplicates,
data: { id: asset.id },
},
]);
});
it('should queue all assets', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset]));
await sut.handleQueueSearchDuplicates({ force: true });
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetDetectDuplicates,
data: { id: asset.id },
},
]);
});
});
describe('delete', () => {
it('should throw for an unknown or unauthorized group id', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
});
it('should dismiss the duplicate group', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.delete.mockResolvedValue();
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
});
});
describe('deleteAll', () => {
it('should throw if any group id is unknown or unauthorized', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
});
it('should dismiss all duplicate groups', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.deleteAll.mockResolvedValue();
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
});
});
describe('resolve', () => {
it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.get.mockResolvedValueOnce(void 0);
mocks.duplicateRepository.get.mockResolvedValueOnce({
duplicateId: 'group-2',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [
{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] },
{ duplicateId: 'group-2', keepAssetIds: [asset.id], trashAssetIds: [] },
],
}),
).resolves.toEqual([
{ id: 'group-1', success: false, error: BulkIdErrorReason.NOT_FOUND },
{ id: 'group-2', success: true },
]);
});
it('should catch and report errors', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockRejectedValue(new Error('Database error'));
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }],
}),
).resolves.toEqual([{ id: 'group-1', success: false, error: BulkIdErrorReason.UNKNOWN }]);
});
});
describe('resolveGroup (via resolve)', () => {
it('should fail if duplicate group not found', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['missing-id']));
mocks.duplicateRepository.get.mockResolvedValue(void 0);
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }],
}),
).resolves.toEqual([
{
id: 'missing-id',
success: false,
error: BulkIdErrorReason.NOT_FOUND,
},
]);
});
it('should skip when keepAssetIds contains non-member', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999', asset.id], trashAssetIds: [] }],
}),
).resolves.toEqual([{ id: 'group-1', success: true }]);
});
it('should skip when trashAssetIds contains non-member', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset.id], trashAssetIds: ['asset-999'] }],
}),
).resolves.toEqual([{ id: 'group-1', success: true }]);
});
it('should fail if keepAssetIds and trashAssetIds overlap', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('An asset cannot be in both keepAssetIds and trashAssetIds');
});
it('should fail if keepAssetIds and trashAssetIds do not cover all assets', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
const asset3 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset, asset3 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
});
it('should fail if partial trash without keepers', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
});
it('should sync merged tags to asset_exif.tags', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [
{
...asset1,
tags: [
{
id: 'tag-1',
value: 'Work',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
parentId: null,
color: null,
},
],
},
{
...asset2,
tags: [
{
id: 'tag-2',
value: 'Travel',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
parentId: null,
color: null,
},
],
},
] as any,
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(result[0].success).toBe(true);
// Verify tags were applied to tag_asset table
expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith(asset1.id, ['tag-1', 'tag-2']);
// Verify merged tag values were written to asset_exif.tags so SidecarWrite preserves them
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith([asset1.id], { tags: ['Work', 'Travel'] });
// Verify SidecarWrite was queued (to write tags to sidecar)
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
});
// NOTE: The following integration-style tests are covered by E2E tests instead
// to avoid complex mock setup. The validation and error-handling logic above
// is thoroughly unit tested.
});
describe('handleSearchDuplicates', () => {
beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: true,
duplicateDetection: {
enabled: true,
},
},
});
});
it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: false,
duplicateDetection: {
enabled: true,
},
},
});
const result = await sut.handleSearchDuplicates({ id: newUuid() });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
});
it('should skip if duplicate detection is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: {
enabled: true,
duplicateDetection: {
enabled: false,
},
},
});
const result = await sut.handleSearchDuplicates({ id: newUuid() });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
});
it('should fail if asset is not found', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(void 0);
const asset = AssetFactory.create();
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Failed);
expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${asset.id} not found`);
});
it('should skip if asset is part of stack', async () => {
const asset = AssetFactory.from().stack().build();
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId });
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`);
});
it('should skip if asset is not visible', async () => {
const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden });
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, ...asset });
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is not visible, skipping`);
});
it('should fail if asset is missing embedding', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null });
const asset = AssetFactory.create();
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Failed);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is missing embedding`);
});
it('should search for duplicates and update asset with duplicateId', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding);
const asset = AssetFactory.create();
mocks.duplicateRepository.search.mockResolvedValue([{ assetId: asset.id, distance: 0.01, duplicateId: null }]);
mocks.duplicateRepository.merge.mockResolvedValue();
const expectedAssetIds = [asset.id, hasEmbedding.id];
const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id });
expect(result).toBe(JobStatus.Success);
expect(mocks.duplicateRepository.search).toHaveBeenCalledWith({
assetId: hasEmbedding.id,
embedding: hasEmbedding.embedding,
maxDistance: 0.01,
type: hasEmbedding.type,
userIds: [hasEmbedding.ownerId],
});
expect(mocks.duplicateRepository.merge).toHaveBeenCalledWith({
assetIds: expectedAssetIds,
targetId: expect.any(String),
sourceIds: [],
});
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith(
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
);
});
it('should use existing duplicate ID among matched duplicates', async () => {
const duplicateId = hasDupe.duplicateId;
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding);
mocks.duplicateRepository.search.mockResolvedValue([{ assetId: hasDupe.id, distance: 0.01, duplicateId }]);
mocks.duplicateRepository.merge.mockResolvedValue();
const expectedAssetIds = [hasEmbedding.id];
const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id });
expect(result).toBe(JobStatus.Success);
expect(mocks.duplicateRepository.search).toHaveBeenCalledWith({
assetId: hasEmbedding.id,
embedding: hasEmbedding.embedding,
maxDistance: 0.01,
type: hasEmbedding.type,
userIds: [hasEmbedding.ownerId],
});
expect(mocks.duplicateRepository.merge).toHaveBeenCalledWith({
assetIds: expectedAssetIds,
targetId: duplicateId,
sourceIds: [],
});
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith(
...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })),
);
});
it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasDupe);
mocks.duplicateRepository.search.mockResolvedValue([]);
const result = await sut.handleSearchDuplicates({ id: hasDupe.id });
expect(result).toBe(JobStatus.Success);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: hasDupe.id, duplicateId: null });
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({
assetId: hasDupe.id,
duplicatesDetectedAt: expect.any(Date),
});
});
});
});