feat: manual stack assets (#4198)

This commit is contained in:
shenlong
2023-10-22 02:38:07 +00:00
committed by GitHub
parent 5ead4af2dc
commit cf08ac7538
59 changed files with 2190 additions and 138 deletions

View File

@@ -1673,6 +1673,41 @@
]
}
},
"/asset/stack/parent": {
"put": {
"operationId": "updateStackParent",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStackParentDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/statistics": {
"get": {
"operationId": "getAssetStats",
@@ -5696,6 +5731,13 @@
},
"isFavorite": {
"type": "boolean"
},
"removeParent": {
"type": "boolean"
},
"stackParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
@@ -5941,6 +5983,19 @@
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto"
},
"stack": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"stackCount": {
"type": "integer"
},
"stackParentId": {
"nullable": true,
"type": "string"
},
"tags": {
"items": {
"$ref": "#/components/schemas/TagResponseDto"
@@ -5961,6 +6016,7 @@
},
"required": [
"type",
"stackCount",
"deviceAssetId",
"deviceId",
"ownerId",
@@ -8521,6 +8577,23 @@
},
"type": "object"
},
"UpdateStackParentDto": {
"properties": {
"newParentId": {
"format": "uuid",
"type": "string"
},
"oldParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"oldParentId",
"newParentId"
],
"type": "object"
},
"UpdateTagDto": {
"properties": {
"name": {

View File

@@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job';
import {
AssetStats,
CommunicationEvent,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
@@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
/// Stack related
it('should require asset update access for parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
await expect(
sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should update parent asset when children are added', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: [],
stackParentId: 'parent',
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
});
it('should update parent asset when children are removed', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
removeParent: true,
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
});
it('update parentId for new children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
stackParentId: 'parent',
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('nullify parentId for remove children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
removeParent: true,
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
});
it('merge stacks if new child has children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
stackParentId: 'parent',
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('should send ws asset update event', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
});
expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
'asset-1',
]);
});
});
describe('deleteAll', () => {
it('should required asset delete access for all ids', async () => {
it('should require asset delete access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@@ -677,7 +757,7 @@ describe(AssetService.name, () => {
});
describe('restoreAll', () => {
it('should required asset restore access for all ids', async () => {
it('should require asset restore access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
});
it('should update stack parent if asset has stack children', async () => {
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
stackParentId: 'stack-child-asset-1',
});
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
stackParentId: null,
});
});
it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id)
@@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
});
});
describe('updateStackParent', () => {
it('should require asset update access for new parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require asset read access for old parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('make old parent the child of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.image.id)
.mockResolvedValue(assetStub.image as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.image.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
});
it('remove stackParentId of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
});
it('update stackParentId of old parents children to new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(
[assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
{ stackParentId: 'new' },
);
});
});
});

View File

@@ -40,6 +40,7 @@ import {
TimeBucketDto,
TrashAction,
UpdateAssetDto,
UpdateStackParentDto,
mapStats,
} from './dto';
import {
@@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset));
} else {
return assets.map((asset) => mapAsset(asset, true));
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
}
@@ -338,10 +339,29 @@ export class AssetService {
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, ...options } = dto;
const { ids, removeParent, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
if (removeParent) {
(options as Partial<AssetEntity>).stackParentId = null;
const assets = await this.assetRepository.getByIds(ids);
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
} else if (options.stackParentId) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
// Merge stacks
const assets = await this.assetRepository.getByIds(ids);
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
// This updates the updatedAt column of the parent to indicate that a new child has been added
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
}
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
}
async handleAssetDeletionCheck() {
@@ -384,6 +404,14 @@ export class AssetService {
);
}
// Replace the parent of the stack children with a new asset
if (asset.stack && asset.stack.length != 0) {
const stackIds = asset.stack.map((a) => a.id);
const newParentId = stackIds[0];
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
await this.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
@@ -454,6 +482,25 @@ export class AssetService {
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
}
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
const { oldParentId, newParentId } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
const childIds: string[] = [];
const oldParent = await this.assetRepository.getById(oldParentId);
if (oldParent != null) {
childIds.push(oldParent.id);
// Get all children of old parent
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
}
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
// Remove ParentId of new parent if this was previously a child of some other asset
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);

View File

@@ -0,0 +1,9 @@
import { ValidateUUID } from '../../domain.util';
export class UpdateStackParentDto {
@ValidateUUID()
oldParentId!: string;
@ValidateUUID()
newParentId!: string;
}

View File

@@ -1,6 +1,6 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util';
import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto {
@@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
isArchived?: boolean;
@Optional()
@ValidateUUID()
stackParentId?: string;
@Optional()
@IsBoolean()
removeParent?: boolean;
}
export class UpdateAssetDto {

View File

@@ -1,4 +1,5 @@
export * from './asset-ids.dto';
export * from './asset-stack.dto';
export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto';

View File

@@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
stack?: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
stackCount!: number;
}
export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
export type AssetMapOptions = {
stripMetadata?: boolean;
withStack?: boolean;
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
stackCount: entity.stack?.length ?? 0,
isExternal: entity.isExternal,
isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly,

View File

@@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version',

View File

@@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,

View File

@@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
relations: {
exifInfo: true,
tags: true,
stack: true,
},
skip: dto.skip || 0,
order: {

View File

@@ -196,7 +196,7 @@ export class AssetService {
const includeMetadata = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
if (includeMetadata) {
const data = mapAsset(asset);
const data = mapAsset(asset, { withStack: true });
if (data.ownerId !== authUser.id) {
data.people = [];
@@ -208,7 +208,7 @@ export class AssetService {
return data;
} else {
return mapAsset(asset, true);
return mapAsset(asset, { stripMetadata: true, withStack: true });
}
}

View File

@@ -21,6 +21,7 @@ import {
TimeBucketResponseDto,
TrashAction,
UpdateAssetDto as UpdateDto,
UpdateStackParentDto,
} from '@app/domain';
import {
Body,
@@ -137,6 +138,12 @@ export class AssetController {
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
}
@Put('stack/parent')
@HttpCode(HttpStatus.OK)
updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise<void> {
return this.service.updateStackParent(authUser, dto);
}
@Put(':id')
updateAsset(
@AuthUser() authUser: AuthUserDto,

View File

@@ -148,6 +148,16 @@ export class AssetEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
faces!: AssetFaceEntity[];
@Column({ nullable: true })
stackParentId?: string | null;
@ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'stackParentId' })
stackParent?: AssetEntity | null;
@OneToMany(() => AssetEntity, (asset) => asset.stackParent)
stack?: AssetEntity[];
}
export enum AssetType {

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddStackParentIdToAssets1695354433573 implements MigrationInterface {
name = 'AddStackParentIdToAssets1695354433573'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`);
}
}

View File

@@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
stack: true,
},
withDeleted: true,
});
@@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
library: true,
stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
.andWhere('person.id = :personId', { personId });
}
// Hide stack children only in main timeline
// Uncomment after adding support for stacked assets in web client
// if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
// builder = builder.andWhere('asset.stackParent IS NULL');
// }
return builder;
}
}

View File

@@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
});
});
describe('PUT /asset', () => {
beforeEach(async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
});
it('should require authentication', async () => {
const { status, body } = await request(server).put('/asset');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid parent id', async () => {
const { status, body } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
});
it('should require access to the parent', async () => {
const { status, body } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset4.id, ids: [asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should add stack children', async () => {
const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: parent.id, ids: [child.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
});
it('should remove stack children', async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ removeParent: true, ids: [asset2.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
});
it('should remove all stack children', async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ removeParent: true, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
expect(asset.stack).toHaveLength(0);
});
it('should merge stack children', async () => {
const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: newParent.id, ids: [asset1.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
expect.objectContaining({ id: asset3.id }),
]),
);
});
});
describe('PUT /asset/stack/parent', () => {
beforeEach(async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
});
it('should require authentication', async () => {
const { status, body } = await request(server).put('/asset/stack/parent');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should require access', async () => {
const { status, body } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset4.id, newParentId: asset1.id });
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should make old parent child of new parent', async () => {
const { status } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset1.id, newParentId: asset2.id });
expect(status).toBe(200);
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
});
it('should make all childrens of old parent, a child of new parent', async () => {
const { status } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset1.id, newParentId: asset2.id });
expect(status).toBe(200);
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
});
});
});

View File

@@ -41,6 +41,7 @@ export const assetStub = {
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
}),
noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -80,6 +81,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -116,6 +118,7 @@ export const assetStub = {
sidecarPath: null,
deletedAt: null,
}),
primaryImage: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -154,7 +157,9 @@ export const assetStub = {
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
}),
image: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -194,6 +199,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
external: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -233,6 +239,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
offline: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -272,6 +279,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
image1: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@@ -311,6 +319,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
imageFrom2015: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@@ -350,6 +359,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
video: Object.freeze<AssetEntity>({
id: 'asset-id',
originalFileName: 'asset-id.ext',
@@ -389,6 +399,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset',
originalPath: fileStub.livePhotoMotion.originalPath,
@@ -497,10 +508,41 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
}),
readOnly: Object.freeze({
readOnly: Object.freeze<AssetEntity>({
id: 'read-only-asset',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: true,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
}),
};

View File

@@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
stackCount: 0,
};
const assetResponseWithoutMetadata = {