mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 20:25:32 -04:00
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
|
import { DuplicateService } from 'src/services/duplicate.service';
|
|
import { SearchService } from 'src/services/search.service';
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
|
import { authStub } from 'test/fixtures/auth.stub';
|
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
|
import { beforeEach, 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(SearchService.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 () => {
|
|
mocks.asset.getDuplicates.mockResolvedValue([
|
|
{
|
|
duplicateId: 'duplicate-id',
|
|
assets: [assetStub.image, assetStub.image],
|
|
},
|
|
]);
|
|
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
|
{
|
|
duplicateId: 'duplicate-id',
|
|
assets: [
|
|
expect.objectContaining({ id: assetStub.image.id }),
|
|
expect.objectContaining({ id: assetStub.image.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 () => {
|
|
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
|
|
|
await sut.handleQueueSearchDuplicates({});
|
|
|
|
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined);
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
data: { id: assetStub.image.id },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should queue all assets', async () => {
|
|
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
|
|
|
await sut.handleQueueSearchDuplicates({ force: true });
|
|
|
|
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true);
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{
|
|
name: JobName.DUPLICATE_DETECTION,
|
|
data: { id: assetStub.image.id },
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
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 id = assetStub.livePhotoMotionAsset.id;
|
|
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
});
|
|
|
|
it('should skip if duplicate detection is disabled', async () => {
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
machineLearning: {
|
|
enabled: true,
|
|
duplicateDetection: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
});
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
|
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
});
|
|
|
|
it('should fail if asset is not found', async () => {
|
|
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(void 0);
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
|
expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`);
|
|
});
|
|
|
|
it('should skip if asset is part of stack', async () => {
|
|
const id = assetStub.primaryImage.id;
|
|
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' });
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`);
|
|
});
|
|
|
|
it('should skip if asset is not visible', async () => {
|
|
const id = assetStub.livePhotoMotionAsset.id;
|
|
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({
|
|
...hasEmbedding,
|
|
visibility: AssetVisibility.HIDDEN,
|
|
});
|
|
|
|
const result = await sut.handleSearchDuplicates({ id });
|
|
|
|
expect(result).toBe(JobStatus.SKIPPED);
|
|
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`);
|
|
});
|
|
|
|
it('should fail if asset is missing embedding', async () => {
|
|
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null });
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: assetStub.image.id });
|
|
|
|
expect(result).toBe(JobStatus.FAILED);
|
|
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`);
|
|
});
|
|
|
|
it('should search for duplicates and update asset with duplicateId', async () => {
|
|
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding);
|
|
mocks.search.searchDuplicates.mockResolvedValue([
|
|
{ assetId: assetStub.image.id, distance: 0.01, duplicateId: null },
|
|
]);
|
|
const expectedAssetIds = [assetStub.image.id, hasEmbedding.id];
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id });
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
|
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({
|
|
assetId: hasEmbedding.id,
|
|
embedding: hasEmbedding.embedding,
|
|
maxDistance: 0.01,
|
|
type: hasEmbedding.type,
|
|
userIds: [hasEmbedding.ownerId],
|
|
});
|
|
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({
|
|
assetIds: expectedAssetIds,
|
|
targetDuplicateId: expect.any(String),
|
|
duplicateIds: [],
|
|
});
|
|
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.search.searchDuplicates.mockResolvedValue([{ assetId: hasDupe.id, distance: 0.01, duplicateId }]);
|
|
const expectedAssetIds = [hasEmbedding.id];
|
|
|
|
const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id });
|
|
|
|
expect(result).toBe(JobStatus.SUCCESS);
|
|
expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({
|
|
assetId: hasEmbedding.id,
|
|
embedding: hasEmbedding.embedding,
|
|
maxDistance: 0.01,
|
|
type: hasEmbedding.type,
|
|
userIds: [hasEmbedding.ownerId],
|
|
});
|
|
expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({
|
|
assetIds: expectedAssetIds,
|
|
targetDuplicateId: duplicateId,
|
|
duplicateIds: [],
|
|
});
|
|
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.search.searchDuplicates.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),
|
|
});
|
|
});
|
|
});
|
|
});
|