diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts deleted file mode 100644 index d6d0ec1394..0000000000 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ /dev/null @@ -1,651 +0,0 @@ -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); - }); - }); -}); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f0f14757b2..860189d9a9 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -29,6 +29,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; import { EmailRepository } from 'src/repositories/email.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { IntegrityRepository } from 'src/repositories/integrity.repository'; @@ -445,6 +446,7 @@ const newRealRepository = (key: T, db: Kysely case AssetRepository: case AssetEditRepository: case AssetJobRepository: + case DuplicateRepository: case IntegrityRepository: case MemoryRepository: case NotificationRepository: diff --git a/server/test/medium/specs/services/duplicate.service.spec.ts b/server/test/medium/specs/services/duplicate.service.spec.ts new file mode 100644 index 0000000000..6afae277cb --- /dev/null +++ b/server/test/medium/specs/services/duplicate.service.spec.ts @@ -0,0 +1,622 @@ +import { Kysely } from 'kysely'; +import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; +import { AssetStatus, AssetVisibility } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { DB } from 'src/schema'; +import { DuplicateService } from 'src/services/duplicate.service'; +import { clearConfigCache } from 'src/utils/config'; +import { MediumTestContext, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { sut, ctx } = newMediumService(DuplicateService, { + database: db || defaultDatabase, + real: [ + AccessRepository, + AlbumRepository, + AssetRepository, + ConfigRepository, + DuplicateRepository, + SystemMetadataRepository, + TagRepository, + ], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + + return { sut, ctx }; +}; + +const newDuplicateAsset = async ( + ctx: MediumTestContext, + { + ownerId, + duplicateId, + ...dto + }: { ownerId: string; duplicateId?: string; isFavorite?: boolean; visibility?: AssetVisibility }, + exif: { rating?: number; description?: string; latitude?: number; longitude?: number; fileSizeInByte?: number } = {}, +) => { + const { asset } = await ctx.newAsset({ ownerId, duplicateId, ...dto }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 1000, ...exif }); + return asset; +}; + +const newTag = async (ctx: MediumTestContext, userId: string, value: string) => { + return ctx.get(TagRepository).create({ userId, value }); +}; + +const expectSuccess = (results: BulkIdResponseDto[], duplicateId: string) => { + expect(results).toEqual([{ id: duplicateId, success: true }]); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +beforeEach(() => { + clearConfigCache(); +}); + +describe(DuplicateService.name, () => { + describe('getDuplicates', () => { + it('should return an empty list when the user has no duplicates', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should return a duplicate group with a suggested keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const small = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 1000 }); + const large = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 2000 }); + + const auth = factory.auth({ user: { id: user.id } }); + const duplicates = await sut.getDuplicates(auth); + + expect(duplicates).toHaveLength(1); + expect(duplicates[0].duplicateId).toBe(duplicateId); + expect(duplicates[0].assets.map(({ id }) => id).sort()).toEqual([small.id, large.id].sort()); + // largest file size wins + expect(duplicates[0].suggestedKeepAssetIds).toEqual([large.id]); + }); + + it('should not return duplicates owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should clear the duplicateId of a singleton group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ duplicateId: null }); + }); + }); + + describe('resolve', () => { + it('should throw when the duplicate group does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.resolve(auth, { groups: [{ duplicateId: factory.uuid(), keepAssetIds: [], trashAssetIds: [] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should throw when the duplicate group belongs to another user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should clear the duplicateId of the keeper and trash the rest', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(keeper.id)).resolves.toMatchObject({ + duplicateId: null, + deletedAt: null, + }); + await expect(assetRepo.getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + }); + + it('should emit AssetTrashAll for the trashed assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + duplicateId, + ); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetTrashAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + + it('should fail when an asset is in both keepAssetIds and trashAssetIds', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds', + }, + ]); + }); + + it('should fail when the group is only partially covered', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + }); + + it('should ignore ids that are not members of the group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const outsider = await newDuplicateAsset(ctx, { ownerId: user.id }); + + // the outsider is filtered out, which leaves asset2 uncovered + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsider.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(outsider.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should trash every asset when no keepers are given', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + for (const { id } of [asset1, asset2]) { + await expect(assetRepo.getById(id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + } + }); + + it('should fail without permission to delete an asset owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const mine = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const theirs = await newDuplicateAsset(ctx, { ownerId: other.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [mine.id], trashAssetIds: [theirs.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.NO_PERMISSION, + errorMessage: 'No permission to delete assets', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(theirs.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should resolve several groups in one request', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId1 = factory.uuid(); + const duplicateId2 = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const trashed1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + const trashed2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [ + { duplicateId: duplicateId1, keepAssetIds: [keeper1.id], trashAssetIds: [trashed1.id] }, + { duplicateId: duplicateId2, keepAssetIds: [keeper2.id], trashAssetIds: [trashed2.id] }, + ], + }), + ).resolves.toEqual([ + { id: duplicateId1, success: true }, + { id: duplicateId2, success: true }, + ]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(trashed1.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + await expect(assetRepo.getById(trashed2.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + }); + + it('should report per-group failures without abandoning the valid groups', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const goodId = factory.uuid(); + const badId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + // the bad group exists (so access passes) but is left partially covered + const badKeeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + + const auth = factory.auth({ user: { id: user.id } }); + const results = await sut.resolve(auth, { + groups: [ + { duplicateId: goodId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }, + { duplicateId: badId, keepAssetIds: [badKeeper.id], trashAssetIds: [] }, + ], + }); + + expect(results).toEqual([ + { id: goodId, success: true }, + { + id: badId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + // the good group still went through + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + status: AssetStatus.Trashed, + }); + }); + + describe('metadata merging', () => { + it('should merge isFavorite into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeper.id)).resolves.toMatchObject({ + isFavorite: true, + duplicateId: null, + }); + }); + + it('should merge the most restrictive visibility into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeperAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Timeline, + }); + const trashedAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Archive, + }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeperAsset.id], trashAssetIds: [trashedAsset.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeperAsset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + duplicateId: null, + }); + }); + + it('should merge the highest rating into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 5 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ rating: 5 }); + }); + + it('should merge distinct description lines into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { description: 'from keeper' }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { description: 'from trashed' }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo?.description?.split('\n').sort()).toEqual(['from keeper', 'from trashed']); + }); + + it('should merge a location that both assets agree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { latitude: 40.7128, longitude: -74.006 }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 40.7128, longitude: -74.006 }); + }); + + it('should not merge a location the assets disagree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 1, longitude: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 2, longitude: 2 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 1, longitude: 1 }); + }); + + it('should add the keeper to every album the group belonged to', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }, [keeper.id]); + const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }, [trashed.id]); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const albumRepo = ctx.get(AlbumRepository); + await expect(albumRepo.getAssetIds(album1.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + await expect(albumRepo.getAssetIds(album2.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + }); + + it('should merge the tags of the group onto the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const tag1 = await newTag(ctx, user.id, 'tag1'); + const tag2 = await newTag(ctx, user.id, 'tag2'); + await ctx.newTagAsset({ tagIds: [tag1.id], assetIds: [keeper.id] }); + await ctx.newTagAsset({ tagIds: [tag2.id], assetIds: [trashed.id] }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { tags: true }); + expect(result?.tags?.map(({ id }) => id).sort()).toEqual([tag1.id, tag2.id].sort()); + }); + + it('should not merge metadata when there is more than one keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper1.id, keeper2.id], trashAssetIds: [] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(keeper1.id)).resolves.toMatchObject({ + isFavorite: false, + duplicateId: null, + }); + }); + }); + + describe('when trash is disabled', () => { + it('should delete instead of trash', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const config = await ctx.getConfig(); + await ctx.updateConfig({ ...config, trash: { ...config.trash, enabled: false } }); + clearConfigCache(); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Deleted, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetDeleteAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + }); + }); +});