1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): delete album (#2570)

This commit is contained in:
Jason Rasmussen 2023-05-26 09:04:09 -04:00 committed by GitHub
parent 065fb166c2
commit b7516f31c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 116 additions and 88 deletions

View File

@ -6,18 +6,15 @@ import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository { export interface IAlbumRepository {
get(albumId: string): Promise<AlbumEntity | null>; get(albumId: string): Promise<AlbumEntity | null>;
delete(album: AlbumEntity): Promise<void>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
removeUser(album: AlbumEntity, userId: string): Promise<void>; removeUser(album: AlbumEntity, userId: string): Promise<void>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>; removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>; addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
updateThumbnails(): Promise<number | undefined>; updateThumbnails(): Promise<number | undefined>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>; getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>; getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
@ -62,10 +59,6 @@ export class AlbumRepository implements IAlbumRepository {
}); });
} }
async delete(album: AlbumEntity): Promise<void> {
await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId });
}
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> { async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
album.sharedUsers.push(...addUsersDto.sharedUserIds.map((id) => ({ id } as UserEntity))); album.sharedUsers.push(...addUsersDto.sharedUserIds.map((id) => ({ id } as UserEntity)));
album.updatedAt = new Date().toISOString(); album.updatedAt = new Date().toISOString();
@ -128,13 +121,6 @@ export class AlbumRepository implements IAlbumRepository {
}; };
} }
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
album.albumName = updateAlbumDto.albumName || album.albumName;
album.albumThumbnailAssetId = updateAlbumDto.albumThumbnailAssetId || album.albumThumbnailAssetId;
return this.albumRepository.save(album);
}
/** /**
* Makes sure all thumbnails for albums are updated by: * Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets * - Removing thumbnails from albums without assets

View File

@ -77,12 +77,6 @@ export class AlbumController {
return this.service.removeAssets(authUser, id, dto); return this.service.removeAssets(authUser, id, dto);
} }
@Authenticated()
@Delete(':id')
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id);
}
@Authenticated() @Authenticated()
@Delete(':id/user/:userId') @Delete(':id/user/:userId')
removeUserFromAlbum( removeUserFromAlbum(

View File

@ -121,11 +121,9 @@ describe('Album service', () => {
albumRepositoryMock = { albumRepositoryMock = {
addAssets: jest.fn(), addAssets: jest.fn(),
addSharedUsers: jest.fn(), addSharedUsers: jest.fn(),
delete: jest.fn(),
get: jest.fn(), get: jest.fn(),
removeAssets: jest.fn(), removeAssets: jest.fn(),
removeUser: jest.fn(), removeUser: jest.fn(),
updateAlbum: jest.fn(),
updateThumbnails: jest.fn(), updateThumbnails: jest.fn(),
getCountByUserId: jest.fn(), getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(), getSharedWithUserAlbumCount: jest.fn(),
@ -197,21 +195,6 @@ describe('Album service', () => {
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
}); });
it('deletes an owned album', async () => {
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.delete.mockImplementation(() => Promise.resolve());
await sut.delete(authUser, albumId);
expect(albumRepositoryMock.delete).toHaveBeenCalledTimes(1);
expect(albumRepositoryMock.delete).toHaveBeenCalledWith(albumEntity);
});
it('prevents deleting a shared album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.delete(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
});
it('removes a shared user from an owned album', async () => { it('removes a shared user from an owned album', async () => {
const albumEntity = _getOwnedSharedAlbum(); const albumEntity = _getOwnedSharedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));

View File

@ -3,7 +3,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities'; import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { AddUsersDto } from './dto/add-users.dto'; import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum } from '@app/domain'; import { AlbumResponseDto, IJobRepository, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -64,17 +64,6 @@ export class AlbumService {
return mapAlbum(updatedAlbum); return mapAlbum(updatedAlbum);
} }
async delete(authUser: AuthUserDto, albumId: string): Promise<void> {
const album = await this._getAlbum({ authUser, albumId });
for (const sharedLink of album.sharedLinks) {
await this.shareCore.remove(authUser.id, sharedLink.id);
}
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [albumId] } });
}
async removeUser(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> { async removeUser(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
const sharedUserId = userId == 'me' ? authUser.id : userId; const sharedUserId = userId == 'me' ? authUser.id : userId;
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });

View File

@ -1,6 +1,6 @@
/* */ import { AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain'; /* */ import { AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator'; import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator'; import { Authenticated } from '../decorators/authenticated.decorator';
@ -15,7 +15,7 @@ export class AlbumController {
constructor(private service: AlbumService) {} constructor(private service: AlbumService) {}
@Get() @Get()
async getAllAlbums(@GetAuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) { getAllAlbums(@GetAuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) {
return this.service.getAll(authUser, query); return this.service.getAll(authUser, query);
} }
@ -28,4 +28,9 @@ export class AlbumController {
updateAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { updateAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
return this.service.update(authUser, id, dto); return this.service.update(authUser, id, dto);
} }
@Delete(':id')
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id);
}
} }

View File

@ -143,7 +143,31 @@
}, },
{ {
"api_key": [] "api_key": []
}
]
}, },
"delete": {
"operationId": "deleteAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Album"
],
"security": [
{ {
"bearer": [] "bearer": []
}, },
@ -202,39 +226,6 @@
"api_key": [] "api_key": []
} }
] ]
},
"delete": {
"operationId": "deleteAlbum",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
} }
}, },
"/api-key": { "/api-key": {

View File

@ -20,4 +20,5 @@ export interface IAlbumRepository {
getAll(): Promise<AlbumEntity[]>; getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>; create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>; update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
delete(album: AlbumEntity): Promise<void>;
} }

View File

@ -176,7 +176,22 @@ describe(AlbumService.name, () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('should all the owner to update the album', async () => { it('should require a valid thumbnail asset id', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.update(authStub.admin, albumStub.oneAsset.id, {
albumThumbnailAssetId: 'not-in-album',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album');
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should allow the owner to update the album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset);
@ -195,4 +210,33 @@ describe(AlbumService.name, () => {
}); });
}); });
}); });
describe('delete', () => {
it('should throw an error for an album not found', async () => {
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.delete).not.toHaveBeenCalled();
});
it('should not let a shared user delete the album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(ForbiddenException);
expect(albumMock.delete).not.toHaveBeenCalled();
});
it('should let the owner delete an album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
await sut.delete(authStub.admin, albumStub.empty.id);
expect(albumMock.delete).toHaveBeenCalledTimes(1);
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
});
});
}); });

View File

@ -98,4 +98,18 @@ export class AlbumService {
return mapAlbum(updatedAlbum); return mapAlbum(updatedAlbum);
} }
async delete(authUser: AuthUserDto, id: string): Promise<void> {
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');
}
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
}
} }

View File

@ -14,5 +14,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
hasAsset: jest.fn(), hasAsset: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(),
}; };
}; };

View File

@ -53,7 +53,7 @@ export class SharedLinkEntity {
assets!: AssetEntity[]; assets!: AssetEntity[];
@Index('IDX_sharedlink_albumId') @Index('IDX_sharedlink_albumId')
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks) @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
album?: AlbumEntity; album?: AlbumEntity;
} }

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSharedLinkCascade1685044328272 implements MigrationInterface {
name = 'AddSharedLinkCascade1685044328272'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`);
await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`);
await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
}

View File

@ -143,10 +143,14 @@ export class AlbumRepository implements IAlbumRepository {
return this.save(album); return this.save(album);
} }
async update(album: Partial<AlbumEntity>) { async update(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album); return this.save(album);
} }
async delete(album: AlbumEntity): Promise<void> {
await this.repository.remove(album);
}
private async save(album: Partial<AlbumEntity>) { private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album); const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } }); return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } });