mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
refactor: duplicate e2e
This commit is contained in:
parent
6a61901e79
commit
29f1239ef9
3 changed files with 624 additions and 651 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
622
server/test/medium/specs/services/duplicate.service.spec.ts
Normal file
622
server/test/medium/specs/services/duplicate.service.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue