refactor(server): update album (#2562)

* refactor: update album

* fix: remove unnecessary decorator
This commit is contained in:
Jason Rasmussen
2023-05-25 15:37:19 -04:00
committed by GitHub
parent 1c293a2759
commit 4cc6e3b966
13 changed files with 272 additions and 225 deletions

View File

@@ -10,6 +10,7 @@ export interface AlbumAssetCount {
export interface IAlbumRepository {
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
hasAsset(id: string, assetId: string): Promise<boolean>;
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;
@@ -18,5 +19,5 @@ export interface IAlbumRepository {
deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
}

View File

@@ -1,3 +1,4 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
@@ -89,14 +90,14 @@ describe(AlbumService.name, () => {
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
albumMock.save.mockResolvedValue(albumStub.oneAssetValidThumbnail);
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]);
const result = await sut.getAll(authStub.admin, {});
expect(result).toHaveLength(1);
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
expect(albumMock.save).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledTimes(1);
});
it('removes the thumbnail for an empty album', async () => {
@@ -105,14 +106,14 @@ describe(AlbumService.name, () => {
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
albumMock.save.mockResolvedValue(albumStub.emptyWithValidThumbnail);
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
assetMock.getFirstAssetForAlbumId.mockResolvedValue(null);
const result = await sut.getAll(authStub.admin, {});
expect(result).toHaveLength(1);
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
expect(albumMock.save).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledTimes(1);
});
describe('create', () => {
@@ -151,4 +152,47 @@ describe(AlbumService.name, () => {
});
});
});
describe('update', () => {
it('should prevent updating an album that does not exist', async () => {
albumMock.getByIds.mockResolvedValue([]);
await expect(
sut.update(authStub.user1, 'invalid-id', {
albumName: 'new album name',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should prevent updating a not owned album (shared with auth user)', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(
sut.update(authStub.admin, albumStub.sharedWithAdmin.id, {
albumName: 'new album name',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('should all the owner to update the album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
await sut.update(authStub.admin, albumStub.oneAsset.id, {
albumName: 'new album name',
});
expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-4',
albumName: 'new album name',
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ALBUM,
data: { ids: [albumStub.oneAsset.id] },
});
});
});
});

View File

@@ -1,11 +1,10 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { IAssetRepository } from '../asset';
import { AuthUserDto } from '../auth';
import { IJobRepository, JobName } from '../job';
import { IAlbumRepository } from './album.repository';
import { CreateAlbumDto } from './dto/album-create.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
import { AlbumResponseDto, mapAlbum } from './response-dto';
@Injectable()
@@ -53,7 +52,7 @@ export class AlbumService {
for (const albumId of invalidAlbumIds) {
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
await this.albumRepository.save({ id: albumId, albumThumbnailAsset: newThumbnail });
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
}
return invalidAlbumIds.length;
@@ -71,4 +70,32 @@ export class AlbumService {
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
return mapAlbum(album);
}
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
const [album] = await this.albumRepository.getByIds([id]);
if (!album) {
throw new BadRequestException('Album not found');
}
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
if (!valid) {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update({
id: album.id,
albumName: dto.albumName,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });
return mapAlbum(updatedAlbum);
}
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
import { ValidateUUID } from '../../../../../apps/immich/src/decorators/validate-uuid.decorator';
export class UpdateAlbumDto {
@IsOptional()
@ApiProperty()
albumName?: string;
@ValidateUUID({ optional: true })
albumThumbnailAssetId?: string;
}

View File

@@ -1,2 +1,3 @@
export * from './album-create.dto';
export * from './album-update.dto';
export * from './get-albums.dto';

View File

@@ -11,7 +11,8 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
getNotShared: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
hasAsset: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
};

View File

@@ -123,11 +123,31 @@ export class AlbumRepository implements IAlbumRepository {
});
}
create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
async hasAsset(id: string, assetId: string): Promise<boolean> {
const count = await this.repository.count({
where: {
id,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
});
return Boolean(count);
}
async create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album);
}
async save(album: Partial<AlbumEntity>) {
async update(album: Partial<AlbumEntity>) {
return this.save(album);
}
private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } });
}