mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: bulk asset metadata endpoints (#25133)
This commit is contained in:
@@ -79,6 +79,74 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid() }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] });
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
@@ -169,12 +237,10 @@ describe(AssetController.name, () => {
|
||||
it('should require each item to have a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/metadata`)
|
||||
.send({ items: [{ key: 'someKey' }] });
|
||||
.send({ items: [{ value: { some: 'value' } }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
|
||||
),
|
||||
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,16 +290,6 @@ describe(AssetController.name, () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||
@@ -247,13 +303,5 @@ describe(AssetController.name, () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
AssetBulkUpdateDto,
|
||||
AssetCopyDto,
|
||||
AssetJobsDto,
|
||||
AssetMetadataBulkDeleteDto,
|
||||
AssetMetadataBulkResponseDto,
|
||||
AssetMetadataBulkUpsertDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataRouteParams,
|
||||
AssetMetadataUpsertDto,
|
||||
@@ -120,6 +123,32 @@ export class AssetController {
|
||||
return this.service.copy(auth, dto);
|
||||
}
|
||||
|
||||
@Put('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Upsert asset metadata',
|
||||
description: 'Upsert metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
updateBulkAssetMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: AssetMetadataBulkUpsertDto,
|
||||
): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
return this.service.upsertBulkMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Delete('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete asset metadata',
|
||||
description: 'Delete metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise<void> {
|
||||
return this.service.deleteBulkMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@Endpoint({
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@IsNotEmpty()
|
||||
@@ -142,8 +142,8 @@ export class AssetMetadataRouteParams {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString()
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertDto {
|
||||
@@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto {
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertItemDto {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString()
|
||||
key!: string;
|
||||
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataMobileAppDto {
|
||||
@IsString()
|
||||
@Optional()
|
||||
iCloudId?: string;
|
||||
export class AssetMetadataBulkUpsertDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkUpsertItemDto)
|
||||
items!: AssetMetadataBulkUpsertItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkUpsertItemDto {
|
||||
@ValidateUUID()
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString()
|
||||
key!: string;
|
||||
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkDeleteItemDto)
|
||||
items!: AssetMetadataBulkDeleteItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteItemDto {
|
||||
@ValidateUUID()
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString()
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataResponseDto {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString()
|
||||
key!: string;
|
||||
value!: object;
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class AssetCopyDto {
|
||||
@ValidateUUID()
|
||||
sourceId!: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetMetadataKey,
|
||||
AssetOrder,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
@@ -167,16 +166,14 @@ export class SyncAssetExifV1 {
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataV1 {
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
key!: string;
|
||||
value!: object;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataDeleteV1 {
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
key!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
|
||||
@@ -76,6 +76,14 @@ where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
|
||||
-- AssetRepository.deleteBulkMetadata
|
||||
begin
|
||||
delete from "asset_metadata"
|
||||
where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
commit
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
|
||||
@@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
anyUuid,
|
||||
@@ -256,7 +257,7 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) {
|
||||
upsertMetadata(id: string, items: Array<{ key: string; value: object }>) {
|
||||
return this.db
|
||||
.insertInto('asset_metadata')
|
||||
.values(items.map((item) => ({ assetId: id, ...item })))
|
||||
@@ -269,8 +270,21 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
upsertBulkMetadata(items: Insertable<AssetMetadataTable>[]) {
|
||||
return this.db
|
||||
.insertInto('asset_metadata')
|
||||
.values(items)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['assetId', 'key'])
|
||||
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
|
||||
)
|
||||
.returning(['assetId', 'key', 'value', 'updatedAt'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
|
||||
getMetadataByKey(assetId: string, key: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_metadata')
|
||||
.select(['key', 'value', 'updatedAt'])
|
||||
@@ -280,10 +294,23 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
|
||||
async deleteMetadataByKey(id: string, key: string) {
|
||||
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] })
|
||||
async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.transaction().execute(async (tx) => {
|
||||
for (const { assetId, key } of items) {
|
||||
await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
create(asset: Insertable<AssetTable>) {
|
||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_metadata_audit')
|
||||
@@ -11,7 +10,7 @@ export class AssetMetadataAuditTable {
|
||||
assetId!: string;
|
||||
|
||||
@Column({ index: true })
|
||||
key!: AssetMetadataKey;
|
||||
key!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
|
||||
@@ -32,7 +32,7 @@ export class AssetMetadataTable {
|
||||
assetId!: string;
|
||||
|
||||
@PrimaryColumn({ type: 'character varying' })
|
||||
key!: AssetMetadataKey;
|
||||
key!: AssetMetadataKey | string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
value!: object;
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
AssetCopyDto,
|
||||
AssetJobName,
|
||||
AssetJobsDto,
|
||||
AssetMetadataBulkDeleteDto,
|
||||
AssetMetadataBulkResponseDto,
|
||||
AssetMetadataBulkUpsertDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataUpsertDto,
|
||||
AssetStatsDto,
|
||||
@@ -19,16 +22,7 @@ import {
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetMetadataKey,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
JobName,
|
||||
JobStatus,
|
||||
Permission,
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
@@ -381,12 +375,17 @@ export class AssetService extends BaseService {
|
||||
return this.ocrRepository.getByAssetId(id);
|
||||
}
|
||||
|
||||
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
|
||||
return this.assetRepository.upsertBulkMetadata(dto.items);
|
||||
}
|
||||
|
||||
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
return this.assetRepository.upsertMetadata(id, dto.items);
|
||||
}
|
||||
|
||||
async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<AssetMetadataResponseDto> {
|
||||
async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise<AssetMetadataResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
|
||||
const item = await this.assetRepository.getMetadataByKey(id, key);
|
||||
@@ -396,11 +395,16 @@ export class AssetService extends BaseService {
|
||||
return item;
|
||||
}
|
||||
|
||||
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
|
||||
async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
return this.assetRepository.deleteMetadataByKey(id, key);
|
||||
}
|
||||
|
||||
async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
|
||||
await this.assetRepository.deleteBulkMetadata(dto.items);
|
||||
}
|
||||
|
||||
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
@@ -179,6 +180,12 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
return { asset, result };
|
||||
}
|
||||
|
||||
async newMetadata(dto: Insertable<AssetMetadataTable>) {
|
||||
const { assetId, ...item } = dto;
|
||||
const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]);
|
||||
return { metadata: dto, result };
|
||||
}
|
||||
|
||||
async newAssetFile(dto: Insertable<AssetFileTable>) {
|
||||
const result = await this.get(AssetRepository).upsertFile(dto);
|
||||
return { result };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
|
||||
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
@@ -430,4 +430,177 @@ describe(AssetService.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }];
|
||||
|
||||
await sut.upsertBulkMetadata(auth, { items });
|
||||
|
||||
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
|
||||
expect(metadata.length).toEqual(1);
|
||||
expect(metadata[0]).toEqual(
|
||||
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work on conflict', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } });
|
||||
|
||||
// verify existing metadata
|
||||
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }),
|
||||
]);
|
||||
|
||||
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }];
|
||||
await sut.upsertBulkMetadata(auth, { items });
|
||||
|
||||
// verify updated metadata
|
||||
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work with multiple assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const items = [
|
||||
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } },
|
||||
];
|
||||
|
||||
await sut.upsertBulkMetadata(auth, { items });
|
||||
|
||||
const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id);
|
||||
expect(metadata1).toEqual([
|
||||
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }),
|
||||
]);
|
||||
|
||||
const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id);
|
||||
expect(metadata2).toEqual([
|
||||
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work with multiple metadata for the same asset', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const items = [
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } },
|
||||
];
|
||||
|
||||
await sut.upsertBulkMetadata(auth, { items });
|
||||
|
||||
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
|
||||
expect(metadata).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: AssetMetadataKey.MobileApp,
|
||||
value: { iCloudId: 'id1' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'some-other-key',
|
||||
value: { foo: 'bar' },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBulkMetadata', () => {
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } });
|
||||
|
||||
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
|
||||
|
||||
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
|
||||
expect(metadata.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should work even if the item does not exist', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
|
||||
|
||||
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
|
||||
expect(metadata.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should work with multiple assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
|
||||
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } });
|
||||
|
||||
await sut.deleteBulkMetadata(auth, {
|
||||
items: [
|
||||
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp },
|
||||
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]);
|
||||
await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should work with multiple metadata for the same asset', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
|
||||
|
||||
await sut.deleteBulkMetadata(auth, {
|
||||
items: [
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp },
|
||||
{ assetId: asset.id, key: 'some-other-key' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should not delete unspecified keys', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
|
||||
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
|
||||
|
||||
await sut.deleteBulkMetadata(auth, {
|
||||
items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }],
|
||||
});
|
||||
|
||||
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
|
||||
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
updateByLibraryId: vitest.fn(),
|
||||
getFileSamples: vitest.fn(),
|
||||
getMetadata: vitest.fn(),
|
||||
upsertMetadata: vitest.fn(),
|
||||
getMetadataByKey: vitest.fn(),
|
||||
upsertMetadata: vitest.fn(),
|
||||
upsertBulkMetadata: vitest.fn(),
|
||||
deleteMetadataByKey: vitest.fn(),
|
||||
deleteBulkMetadata: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user