refactor: duplicate e2e

This commit is contained in:
Jason Rasmussen 2026-08-14 16:46:04 -04:00
parent 6a61901e79
commit 29f1239ef9
No known key found for this signature in database
GPG key ID: 75AD31BF84C94773
3 changed files with 624 additions and 651 deletions

View file

@ -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);
});
});
});

View file

@ -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 = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case DuplicateRepository:
case IntegrityRepository:
case MemoryRepository:
case NotificationRepository:

View file

@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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,
});
});
});
});
});