import { LoginResponseDto } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/duplicates', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); [user1, user2] = await Promise.all([ utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), ]); }); beforeEach(async () => { // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs // Note: We don't reset users since they're set up once in beforeAll // Stack must be reset before asset due to foreign key constraint await utils.resetDatabase(['stack', 'asset', 'album', 'tag']); }); describe('GET /duplicates', () => { it('should return empty array when no duplicates', async () => { const { status, body } = await request(app) .get('/duplicates') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toEqual([]); }); it('should return duplicate groups with suggestedKeepAssetIds', async () => { // Create assets with different file sizes for duplicate detection const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Manually set duplicateId on both assets to create a duplicate group const duplicateId = '00000000-0000-4000-8000-000000000001'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .get('/duplicates') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toEqual([ { duplicateId, assets: expect.arrayContaining([ expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id }), ]), suggestedKeepAssetIds: expect.any(Array), }, ]); expect(body[0].suggestedKeepAssetIds.length).toBe(1); }); }); describe('POST /duplicates/resolve', () => { it('should require authentication', async () => { const { status, body } = await request(app) .post('/duplicates/resolve') .send({ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], }); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return failure for non-existent duplicate group', async () => { const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], }); expect(status).toBe(200); expect(body).toEqual({ status: 'COMPLETED', results: [ { duplicateId: uuidDto.dummy, status: 'FAILED', reason: expect.stringContaining('not found or access denied'), }, ], }); }); it('should resolve duplicate group with keepers', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000002'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body).toEqual({ status: 'COMPLETED', results: [ { duplicateId, status: 'SUCCESS', }, ], }); // Verify side effects: duplicateId cleared on kept asset const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.duplicateId).toBeNull(); // Verify side effects: trashed asset is trashed and duplicateId cleared const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); expect(trashedAsset.isTrashed).toBe(true); expect(trashedAsset.duplicateId).toBeNull(); }); it('should reject when keepAssetIds and trashAssetIds overlap', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000003'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('FAILED'); expect(body.results[0].reason).toContain('disjoint'); }); it('should require keepAssetIds when partially trashing', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000004'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('FAILED'); expect(body.results[0].reason).toContain('must cover all assets'); }); it('should reject partial resolution (not all assets covered)', async () => { const [asset1, asset2, asset3] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000010'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('FAILED'); expect(body.results[0].reason).toContain('must cover all assets'); }); it('should reject asset not in duplicate group', async () => { const [asset1, asset2, outsideAsset] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000011'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('FAILED'); expect(body.results[0].reason).toContain('not a member of duplicate group'); }); it('should allow trash-all without keepers', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000012'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], }); expect(status).toBe(200); expect(body).toEqual({ status: 'COMPLETED', results: [ { duplicateId, status: 'SUCCESS', }, ], }); // Verify both assets are trashed const [asset1Info, asset2Info] = await Promise.all([ utils.getAssetInfo(user1.accessToken, asset1.id), utils.getAssetInfo(user1.accessToken, asset2.id), ]); expect(asset1Info.isTrashed).toBe(true); expect(asset1Info.duplicateId).toBeNull(); expect(asset2Info.isTrashed).toBe(true); expect(asset2Info.duplicateId).toBeNull(); }); it('should reject cross-user duplicate group access', async () => { const asset1 = await utils.createAsset(user1.accessToken); const asset2 = await utils.createAsset(user2.accessToken); const duplicateId = '00000000-0000-4000-8000-000000000013'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId); // User1 tries to resolve a group containing user2's asset const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('FAILED'); expect(body.results[0].reason).toContain('not a member of duplicate group'); }); it('should synchronize favorites when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Mark one asset as favorite await request(app) .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset2.id], isFavorite: true }); const duplicateId = '00000000-0000-4000-8000-000000000020'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify favorite was synchronized to keeper const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.isFavorite).toBe(true); expect(keptAsset.duplicateId).toBeNull(); }); it('should synchronize visibility when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Archive one asset await utils.archiveAssets(user1.accessToken, [asset2.id]); const duplicateId = '00000000-0000-4000-8000-000000000021'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify visibility was synchronized to keeper const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.visibility).toBe('archive'); expect(keptAsset.duplicateId).toBeNull(); }); it('should synchronize rating when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Set rating on one asset await request(app) .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset2.id], rating: 5 }); const duplicateId = '00000000-0000-4000-8000-000000000022'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify rating was synchronized to keeper const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.exifInfo?.rating).toBe(5); expect(keptAsset.duplicateId).toBeNull(); }); it('should synchronize description when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Set description on one asset await request(app) .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset2.id], description: 'Test description for duplicate' }); const duplicateId = '00000000-0000-4000-8000-000000000023'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify description was synchronized to keeper const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate'); expect(keptAsset.duplicateId).toBeNull(); }); it('should synchronize location when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Set location on one asset await request(app) .put('/assets') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 }); const duplicateId = '00000000-0000-4000-8000-000000000024'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify location was synchronized to keeper const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.exifInfo?.latitude).toBe(40.7128); expect(keptAsset.exifInfo?.longitude).toBe(-74.006); expect(keptAsset.duplicateId).toBeNull(); }); it('should synchronize albums when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Create albums and add assets to different albums const album1 = await utils.createAlbum(user1.accessToken, { albumName: 'Album 1', assetIds: [asset1.id], }); const album2 = await utils.createAlbum(user1.accessToken, { albumName: 'Album 2', assetIds: [asset2.id], }); const duplicateId = '00000000-0000-4000-8000-000000000025'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify keeper is now in both albums const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.duplicateId).toBeNull(); // Check albums directly const { status: album1Status, body: album1Body } = await request(app) .get(`/albums/${album1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); const { status: album2Status, body: album2Body } = await request(app) .get(`/albums/${album2.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(album1Status).toBe(200); expect(album2Status).toBe(200); expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id); expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id); }); it('should synchronize tags when enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); // Wait for metadata extraction to complete before adding tags // Otherwise, metadata jobs will race and overwrite our tags await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); // Create tags and tag assets differently const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']); await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]); await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]); const duplicateId = '00000000-0000-4000-8000-000000000026'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify keeper has both tags const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(keptAsset.duplicateId).toBeNull(); expect(keptAsset.tags).toBeDefined(); const tagIds = keptAsset.tags?.map((t) => t.id) || []; expect(tagIds).toContain(tags[0].id); expect(tagIds).toContain(tags[1].id); }); it('should handle batch resolve with mixed success and failure', async () => { // Create first group that will succeed const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId1 = '00000000-0000-4000-8000-000000000027'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1); // Create second group with non-existent duplicate ID (will fail) const fakeId = '00000000-0000-4000-8000-000000000099'; const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [ { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }, { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] }, ], }); expect(status).toBe(200); expect(body.status).toBe('COMPLETED'); expect(body.results).toHaveLength(2); // First group should succeed expect(body.results[0].duplicateId).toBe(duplicateId1); expect(body.results[0].status).toBe('SUCCESS'); // Second group should fail expect(body.results[1].duplicateId).toBe(fakeId); expect(body.results[1].status).toBe('FAILED'); expect(body.results[1].reason).toContain('not found or access denied'); // Verify first group was actually resolved despite second failure const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id); expect(asset1Info.duplicateId).toBeNull(); const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id); expect(asset2Info.isTrashed).toBe(true); }); it('should trash assets when trash is enabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000028'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); // Ensure trash is enabled (default) const config = await utils.getSystemConfig(admin.accessToken); expect(config.trash.enabled).toBe(true); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Verify asset is trashed (not deleted) const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); expect(trashedAsset.isTrashed).toBe(true); }); it('should delete assets when trash is disabled', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); const duplicateId = '00000000-0000-4000-8000-000000000029'; await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); // Disable trash await request(app) .put('/system-config') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ trash: { enabled: false, days: 30 }, }); const { status, body } = await request(app) .post('/duplicates/resolve') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], }); expect(status).toBe(200); expect(body.results[0].status).toBe('SUCCESS'); // Asset should be marked as deleted (force delete) const { status: getStatus } = await request(app) .get(`/assets/${asset2.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); // Asset should still be accessible (soft deleted) but marked as deleted expect(getStatus).toBe(200); // Re-enable trash for other tests await utils.resetAdminConfig(admin.accessToken); }); }); });