From 4ae7cadeaed60e628a89bac0ea76ee2290964613 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:43:47 +0100 Subject: [PATCH] feat: asset copy (#23172) --- mobile/openapi/README.md | Bin 41321 -> 41446 bytes mobile/openapi/lib/api.dart | Bin 14718 -> 14752 bytes mobile/openapi/lib/api/assets_api.dart | Bin 43858 -> 45079 bytes mobile/openapi/lib/api_client.dart | Bin 36544 -> 36620 bytes mobile/openapi/lib/model/asset_copy_dto.dart | Bin 0 -> 4154 bytes mobile/openapi/lib/model/permission.dart | Bin 24215 -> 24361 bytes open-api/immich-openapi-specs.json | 75 ++++++++ open-api/typescript-sdk/src/fetch-client.ts | 22 +++ server/src/controllers/asset.controller.ts | 8 + server/src/dtos/asset.dto.ts | 23 +++ server/src/enum.ts | 1 + server/src/queries/album.repository.sql | 12 ++ .../queries/shared.link.asset.repository.sql | 13 ++ server/src/queries/stack.repository.sql | 7 + server/src/repositories/album.repository.ts | 14 ++ .../shared-link-asset.repository.ts | 15 ++ server/src/repositories/stack.repository.ts | 5 + server/src/services/asset.service.ts | 79 ++++++++ server/src/utils/access.ts | 4 + .../specs/services/asset.service.spec.ts | 174 +++++++++++++++++- 20 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_copy_dto.dart create mode 100644 server/src/queries/shared.link.asset.repository.sql diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 02bb0bc0c0fd310c400d9c8fc53b68d3507ba8c2..c234aa4314c962d79d0b61dc6190a205c34366c1 100644 GIT binary patch delta 74 zcmaEPi0Rp3rVSlJEXnx=m6HP{rJ)Q7NeJVCgy`lDA@@*mXP~@GNq($GN`A7wzGHE5 SY6(ba@`rS(%{S5~S^@wp#2n)Q delta 23 fcmaEMnCayqrVSlJo9_y_hfc1`;M^RVG0_qLi*gE` diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 503a71ecb820a7c8957fd4d1ed13f27da39dd60a..ab88670bcd7bf8a8ba6c61904ef3d3b8055459b5 100644 GIT binary patch delta 21 ccmexYw4iuHH#bXienI8rKt8$6-Q1sb0c&#!0ssI2 delta 12 TcmZ2b{I6(3H}~eX+@Ey;EMW#! diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 384fe0d72ac1dcd0b1dab1d729310109bbe2f1e1..7bae14bb58d3d3c813a1fcbc1483ad90b4c2a218 100644 GIT binary patch delta 254 zcmca~jcNJ=rVU#KS(5V$Dkm38NlmsFj}~$Ua$HLCV-*sMi&INLyvdC1$~qu5jv$fn z%#sX`l9B??ytI4`un1HKl7{&95LSJVj>(P8&MaUv4sw5=tlPK?W|tLqdn6~%FJ)7J y+l9;gd3;)OSlxqg^SZ91lM|}sfL;ol{J<)D@@;YM$!_MNlMU)6Ht!RfG6MjzkYeWm delta 18 acmbRKfa%gTrVU#KC+l{XZT=vpJ`(^~2?*2x diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b20c04a2bf8213aa342c0323798b96ea90751a25..5139c5cf622b11e13d49424be2a4cdba20c881f8 100644 GIT binary patch delta 36 ocmX>wm#JqS(}oUZ7U%qe%E=RzWgrYj9gWHNlsPv~RMyW10Q;8>4gdfE delta 14 VcmeB~$8=yW(}oV^%~zByvH>t<1~dQw diff --git a/mobile/openapi/lib/model/asset_copy_dto.dart b/mobile/openapi/lib/model/asset_copy_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..ba19cb1dbce42eb7b433371c2c9c811f5956359f GIT binary patch literal 4154 zcmbVPZExE)5dQ98aV>({!Bl(Ory{ML22C=gOJktT1`I|ZFfwhkkwulHVi;-u`|kK6 zQMQ%@`;eGA-W~7dxjS++>WxNl^4InB?H^}XXJ1dQ&rad|;_@tk(2C2%7TU1Fq+*T-iiP0HitEn9a#3)d=CW8Kn_00U zefMjct(eli9v){vE!Fty~yi98-+Kmcx~%)!~-d ztw8J~_stud2dkh?(Ac{Rt=-@o$i=Bg)%F zYcE8Pq{DhIlAX zEqCo@AqtkmcO+qH?{PmozPHH%%P~`Z=T-lP`asi|c9?>r4O^h>Fh;I7yb*Di-Nhtg zb%b3uAE$`VQ+63Cei0Xa?y!W2He7+?7?thpHabLjq^|ekjt{JugBy4ngwb%%@``I% zD7p4}W|o9QVu1{OnjkaG>P-SgmFKQ8ZG^V7BdWhr@db`8JMmPNVaL-c^}-YpV?>tX ztX#=C)uSV{=FYJ_--5)0#@Q7RQ$m`3W9z56g%-PgYMf|u5C>>M z&Z-z@Oq)IM4crhRWj@UDIRkqNZ8}UI!J3tSQvj!n>ut$D(D0cI2-ykLz&(WH<7kMI zc);c}3IbcdLq!DHXS76|or)ZwpHt%Npvw}E^gRu(4>a)z&7^<$%FTERg>5K%qU}Ih zo-W%%VeHB89vv8?qdjIGZBBi%3E_23d7NGf#7T^T3FJsR$AW1A)@A*{-anQ~ZrE*3 zcccZ&HBTM`6de#1*Hvz)`?VC$ZW=xCA?bhZ|<#37Zv^#Tp{0imQch4gA{%RW{|r9tKo*?jKGEs}KMq^?AITEX>F zMv_Z|5TYXpE33tJGavbQmc&+1sO&o9AIx;&KrRY#7)LD?VakxFA=G%@laaQ3W0#5l z>K@pM{k!7`>L)_Vw8qB>Jv!Wlq?I+Ks%^;IkiG=h!4rz2^XBRNStUcu6J_I>JYs-m zPVkOzX}(%{hruR&A&sqMqG(+x=rVrNps*2=?v}d|W`!iG6?(YD(SPe$P{Pk2ydfYf zz1~O@LZ<@(gqn7_SR4?!6AKBvtQPUW7$qIY{2#2m5C$)Slpi`#oj;2h^r@ZLIQm0M zNgc8XI7wZKO;IK_nmQ^PkBLAqg6kEp^IgB~5Ly~9ZVXdfArx*o;8j86u>^SrpTAA7 o=o#uWZtoA+VBA~#Pm--mc=BLF(XZ!-n { + return this.service.copy(auth, dto); + } + @Get(':id/metadata') @Authenticated({ permission: Permission.AssetRead }) getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 6a89b7e2c..dc43a0200 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -186,6 +186,29 @@ export class AssetMetadataResponseDto { updatedAt!: Date; } +export class AssetCopyDto { + @ValidateUUID() + sourceId!: string; + + @ValidateUUID() + targetId!: string; + + @ValidateBoolean({ optional: true, default: true }) + sharedLinks?: boolean; + + @ValidateBoolean({ optional: true, default: true }) + albums?: boolean; + + @ValidateBoolean({ optional: true, default: true }) + sidecar?: boolean; + + @ValidateBoolean({ optional: true, default: true }) + stack?: boolean; + + @ValidateBoolean({ optional: true, default: true }) + favorite?: boolean; +} + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], diff --git a/server/src/enum.ts b/server/src/enum.ts index 0fc8323c5..0755f75f7 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -95,6 +95,7 @@ export enum Permission { AssetDownload = 'asset.download', AssetUpload = 'asset.upload', AssetReplace = 'asset.replace', + AssetCopy = 'asset.copy', AlbumCreate = 'album.create', AlbumRead = 'album.read', diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 008721773..1f4eda96a 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -422,3 +422,15 @@ group by "asset"."ownerId" order by "assetCount" desc + +-- AlbumRepository.copyAlbums +insert into + "album_asset" +select + "album_asset"."albumsId", + $1 as "assetsId" +from + "album_asset" +where + "album_asset"."assetsId" = $2 +on conflict do nothing diff --git a/server/src/queries/shared.link.asset.repository.sql b/server/src/queries/shared.link.asset.repository.sql new file mode 100644 index 000000000..7acee5081 --- /dev/null +++ b/server/src/queries/shared.link.asset.repository.sql @@ -0,0 +1,13 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- SharedLinkAssetRepository.copySharedLinks +insert into + "shared_link_asset" +select + $1 as "assetsId", + "shared_link_asset"."sharedLinksId" +from + "shared_link_asset" +where + "shared_link_asset"."assetsId" = $2 +on conflict do nothing diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index 94a24f69e..0bfb5df2f 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -153,3 +153,10 @@ from left join "stack" on "stack"."id" = "asset"."stackId" where "asset"."id" = $1 + +-- StackRepository.merge +update "asset" +set + "stackId" = $1 +where + "asset"."stackId" = $2 diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 00c1dfda7..f5bfe44ef 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -397,4 +397,18 @@ export class AlbumRepository { .orderBy('assetCount', 'desc') .execute(); } + + @GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] }) + async copyAlbums({ sourceAssetId, targetAssetId }: { sourceAssetId: string; targetAssetId: string }) { + return this.db + .insertInto('album_asset') + .expression((eb) => + eb + .selectFrom('album_asset') + .select((eb) => ['album_asset.albumsId', eb.val(targetAssetId).as('assetsId')]) + .where('album_asset.assetsId', '=', sourceAssetId), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + } } diff --git a/server/src/repositories/shared-link-asset.repository.ts b/server/src/repositories/shared-link-asset.repository.ts index 45085c4a8..ab164683c 100644 --- a/server/src/repositories/shared-link-asset.repository.ts +++ b/server/src/repositories/shared-link-asset.repository.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; import { DB } from 'src/schema'; export class SharedLinkAssetRepository { @@ -15,4 +16,18 @@ export class SharedLinkAssetRepository { return deleted.map((row) => row.assetsId); } + + @GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] }) + async copySharedLinks({ sourceAssetId, targetAssetId }: { sourceAssetId: string; targetAssetId: string }) { + return this.db + .insertInto('shared_link_asset') + .expression((eb) => + eb + .selectFrom('shared_link_asset') + .select((eb) => [eb.val(targetAssetId).as('assetsId'), 'shared_link_asset.sharedLinksId']) + .where('shared_link_asset.assetsId', '=', sourceAssetId), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + } } diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index ace946817..44db6fbeb 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -162,4 +162,9 @@ export class StackRepository { .where('asset.id', '=', assetId) .executeTakeFirst(); } + + @GenerateSql({ params: [{ sourceId: DummyValue.UUID, targetId: DummyValue.UUID }] }) + merge({ sourceId, targetId }: { sourceId: string; targetId: string }) { + return this.db.updateTable('asset').set({ stackId: targetId }).where('asset.stackId', '=', sourceId).execute(); + } } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index eb66c326e..c1c2fb53c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -7,6 +7,7 @@ import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from import { AssetBulkDeleteDto, AssetBulkUpdateDto, + AssetCopyDto, AssetJobName, AssetJobsDto, AssetMetadataResponseDto, @@ -183,6 +184,84 @@ export class AssetService extends BaseService { } } + async copy( + auth: AuthDto, + { + sourceId, + targetId, + albums = true, + sidecar = true, + sharedLinks = true, + stack = true, + favorite = true, + }: AssetCopyDto, + ) { + await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] }); + const sourceAsset = await this.assetRepository.getById(sourceId); + const targetAsset = await this.assetRepository.getById(targetId); + + if (!sourceAsset || !targetAsset) { + throw new BadRequestException('Both assets must exist'); + } + + if (sourceId === targetId) { + throw new BadRequestException('Source and target id must be distinct'); + } + + if (albums) { + await this.albumRepository.copyAlbums({ sourceAssetId: sourceId, targetAssetId: targetId }); + } + + if (sharedLinks) { + await this.sharedLinkAssetRepository.copySharedLinks({ sourceAssetId: sourceId, targetAssetId: targetId }); + } + + if (stack) { + await this.copyStack(sourceAsset, targetAsset); + } + + if (favorite) { + await this.assetRepository.update({ id: targetId, isFavorite: sourceAsset.isFavorite }); + } + + if (sidecar) { + await this.copySidecar(sourceAsset, targetAsset); + } + } + + private async copyStack( + sourceAsset: { id: string; stackId: string | null }, + targetAsset: { id: string; stackId: string | null }, + ) { + if (!sourceAsset.stackId) { + return; + } + + if (targetAsset.stackId) { + await this.stackRepository.merge({ sourceId: sourceAsset.stackId, targetId: targetAsset.stackId }); + await this.stackRepository.delete(sourceAsset.stackId); + } else { + await this.assetRepository.update({ id: targetAsset.id, stackId: sourceAsset.stackId }); + } + } + + private async copySidecar( + targetAsset: { sidecarPath: string | null }, + sourceAsset: { id: string; sidecarPath: string | null; originalPath: string }, + ) { + if (!targetAsset.sidecarPath) { + return; + } + + if (sourceAsset.sidecarPath) { + await this.storageRepository.unlink(sourceAsset.sidecarPath); + } + + await this.storageRepository.copyFile(targetAsset.sidecarPath, `${sourceAsset.originalPath}.xmp`); + await this.assetRepository.update({ id: sourceAsset.id, sidecarPath: `${sourceAsset.originalPath}.xmp` }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: sourceAsset.id } }); + } + @OnJob({ name: JobName.AssetDeleteCheck, queue: QueueName.BackgroundTask }) async handleAssetDeletionCheck(): Promise { const config = await this.getConfig({ withCache: false }); diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 8427da6f1..7a0f701f7 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -153,6 +153,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetCopy: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 11343bd6e..b3fc48170 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,6 +1,14 @@ import { Kysely } from 'kysely'; +import { JobName, SharedLinkType } 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 { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { DB } from 'src/schema'; import { AssetService } from 'src/services/asset.service'; import { newMediumService } from 'test/medium.factory'; @@ -12,8 +20,8 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(AssetService, { database: db || defaultDatabase, - real: [AssetRepository], - mock: [LoggingRepository], + real: [AssetRepository, AlbumRepository, AccessRepository, SharedLinkAssetRepository, StackRepository], + mock: [LoggingRepository, JobRepository, StorageRepository], }); }; @@ -32,4 +40,166 @@ describe(AssetService.name, () => { await expect(sut.getStatistics(auth, {})).resolves.toEqual({ images: 1, total: 1, videos: 0 }); }); }); + + describe('copy', () => { + it('should copy albums', async () => { + const { sut, ctx } = setup(); + const albumRepo = ctx.get(AlbumRepository); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: oldAsset.id }); + + const auth = factory.auth({ user: { id: user.id } }); + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + await expect(albumRepo.getAssetIds(album.id, [oldAsset.id, newAsset.id])).resolves.toEqual( + new Set([oldAsset.id, newAsset.id]), + ); + }); + + it('should copy shared links', async () => { + const { sut, ctx } = setup(); + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + + const { id: sharedLinkId } = await sharedLinkRepo.create({ + allowUpload: false, + key: Buffer.from('123'), + type: SharedLinkType.Individual, + userId: user.id, + assetIds: [oldAsset.id], + }); + + const auth = factory.auth({ user: { id: user.id } }); + + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + await expect(sharedLinkRepo.get(user.id, sharedLinkId)).resolves.toEqual( + expect.objectContaining({ + assets: [expect.objectContaining({ id: oldAsset.id }), expect.objectContaining({ id: newAsset.id })], + }), + ); + }); + + it('should merge stacks', async () => { + const { sut, ctx } = setup(); + const stackRepo = ctx.get(StackRepository); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: asset1.id, description: 'bar' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + await ctx.newExif({ assetId: asset2.id, description: 'foo' }); + + await ctx.newStack({ ownerId: user.id }, [oldAsset.id, asset1.id]); + + const { + stack: { id: newStackId }, + } = await ctx.newStack({ ownerId: user.id }, [newAsset.id, asset2.id]); + + const auth = factory.auth({ user: { id: user.id } }); + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + await expect(stackRepo.getById(oldAsset.id)).resolves.toEqual(undefined); + + const newStack = await stackRepo.getById(newStackId); + expect(newStack).toEqual( + expect.objectContaining({ + primaryAssetId: newAsset.id, + assets: expect.arrayContaining([expect.objectContaining({ id: asset2.id })]), + }), + ); + expect(newStack!.assets.length).toEqual(4); + }); + + it('should copy stack', async () => { + const { sut, ctx } = setup(); + const stackRepo = ctx.get(StackRepository); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: asset1.id, description: 'bar' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + + const { + stack: { id: stackId }, + } = await ctx.newStack({ ownerId: user.id }, [oldAsset.id, asset1.id]); + + const auth = factory.auth({ user: { id: user.id } }); + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + const stack = await stackRepo.getById(stackId); + expect(stack).toEqual( + expect.objectContaining({ + primaryAssetId: oldAsset.id, + assets: expect.arrayContaining([expect.objectContaining({ id: newAsset.id })]), + }), + ); + expect(stack!.assets.length).toEqual(3); + }); + + it('should copy favorite status', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + + const auth = factory.auth({ user: { id: user.id } }); + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + await expect(assetRepo.getById(newAsset.id)).resolves.toEqual(expect.objectContaining({ isFavorite: true })); + }); + + it('should copy sidecar file', async () => { + const { sut, ctx } = setup(); + const storageRepo = ctx.getMock(StorageRepository); + const jobRepo = ctx.getMock(JobRepository); + + storageRepo.copyFile.mockResolvedValue(); + jobRepo.queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id, sidecarPath: '/path/to/my/sidecar.xmp' }); + const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newExif({ assetId: oldAsset.id, description: 'foo' }); + await ctx.newExif({ assetId: newAsset.id, description: 'bar' }); + + const auth = factory.auth({ user: { id: user.id } }); + + await sut.copy(auth, { sourceId: oldAsset.id, targetId: newAsset.id }); + + expect(storageRepo.copyFile).toHaveBeenCalledWith('/path/to/my/sidecar.xmp', `${newAsset.originalPath}.xmp`); + + expect(jobRepo.queue).toHaveBeenCalledWith({ + name: JobName.AssetExtractMetadata, + data: { id: newAsset.id }, + }); + }); + }); });