You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-10 23:22:22 +02:00
Refactor API for albums feature (#155)
* Rename "shared" to "album" Prepare moving "SharedAlbums" to "Albums" * Update server album API endpoints * Update mobile app album endpoints Also add `putRequest` to mobile network.service * Add GET album collection filter - allow to filter by owner = 'mine' | 'their' - make sharedWithUserIds no longer required when creating an album * Rename remaining variables to "album" * Add ParseMeUUIDPipe to validate uuid or `me` * Add album params validation * Update todo in mobile album service. * Setup e2e testing * Add user e2e tests * Rename database host env variable to DB_HOST * Add some `Album` e2e tests Also fix issues found with the tests * Force push (try to recover DB_HOST env) * Rename db host env variable to `DB_HOSTNAME` * Remove unnecessary `initDb` from test-utils The current database.config is running the migrations: `migrationsRun: true` * Remove `initDb` usage from album e2e test * Update GET albums filter to `shared` - add filter by all / shared / not shared - add response DTOs - add GET albums e2e tests * Update album e2e tests for user.service changes * Update mobile app to use album response DTOs * Refactor album-service DB into album-registry - DB logic refactored into album-repository making it easier to test - add some album-service unit tests - add `clearMocks` to jest configuration * Finish implementing album.service unit tests * Rename response DTO Make them consistent with rest of the project naming * Update debug log messages in mobile network service * Rename table `shared_albums` to `albums` * Rename table `asset_shared_album` * Rename Albums `sharedAssets` to `assets` * Update tests to match updated "delete" response * Fixed asset cannot be compared in Set by adding Equatable package * Remove hero effect to fixed janky animation Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
228
server/apps/immich/src/api-v1/album/album-repository.ts
Normal file
228
server/apps/immich/src/api-v1/album/album-repository.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { getConnection, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
|
||||
export interface IAlbumRepository {
|
||||
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
|
||||
get(albumId: string): Promise<AlbumEntity>;
|
||||
delete(album: AlbumEntity): Promise<void>;
|
||||
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
|
||||
removeUser(album: AlbumEntity, userId: string): Promise<void>;
|
||||
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<boolean>;
|
||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
|
||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||
}
|
||||
|
||||
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(
|
||||
@InjectRepository(AlbumEntity)
|
||||
private albumRepository: Repository<AlbumEntity>,
|
||||
|
||||
@InjectRepository(AssetAlbumEntity)
|
||||
private assetAlbumRepository: Repository<AssetAlbumEntity>,
|
||||
|
||||
@InjectRepository(UserAlbumEntity)
|
||||
private userAlbumRepository: Repository<UserAlbumEntity>,
|
||||
) {}
|
||||
|
||||
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
||||
return await getConnection().transaction(async (transactionalEntityManager) => {
|
||||
// Create album entity
|
||||
const newAlbum = new AlbumEntity();
|
||||
newAlbum.ownerId = ownerId;
|
||||
newAlbum.albumName = createAlbumDto.albumName;
|
||||
|
||||
const album = await transactionalEntityManager.save(newAlbum);
|
||||
|
||||
// Add shared users
|
||||
if (createAlbumDto.sharedWithUserIds?.length) {
|
||||
for (const sharedUserId of createAlbumDto.sharedWithUserIds) {
|
||||
const newSharedUser = new UserAlbumEntity();
|
||||
newSharedUser.albumId = album.id;
|
||||
newSharedUser.sharedUserId = sharedUserId;
|
||||
|
||||
await transactionalEntityManager.save(newSharedUser);
|
||||
}
|
||||
}
|
||||
|
||||
// Add shared assets
|
||||
const newRecords: AssetAlbumEntity[] = [];
|
||||
|
||||
if (createAlbumDto.assetIds?.length) {
|
||||
for (const assetId of createAlbumDto.assetIds) {
|
||||
const newAssetAlbum = new AssetAlbumEntity();
|
||||
newAssetAlbum.assetId = assetId;
|
||||
newAssetAlbum.albumId = album.id;
|
||||
|
||||
newRecords.push(newAssetAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await transactionalEntityManager.save(album);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save([...newRecords]);
|
||||
|
||||
return album;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
|
||||
const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
|
||||
const userId = ownerId;
|
||||
let query = this.albumRepository.createQueryBuilder('album');
|
||||
|
||||
const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder<AlbumEntity>) => {
|
||||
return qb
|
||||
.subQuery()
|
||||
.select('albumSub.id')
|
||||
.from(AlbumEntity, 'albumSub')
|
||||
.innerJoin('albumSub.sharedUsers', 'userAlbumSub')
|
||||
.where('albumSub.ownerId = :ownerId', { ownerId: userId })
|
||||
.getQuery();
|
||||
};
|
||||
|
||||
if (filteringByShared) {
|
||||
if (getAlbumsDto.shared) {
|
||||
// shared albums
|
||||
query = query
|
||||
.innerJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||
.innerJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||
.where((qb) => {
|
||||
// owned and shared with other users
|
||||
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||
return `album.id IN ${subQuery}`;
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
// shared with userId
|
||||
const subQuery = qb
|
||||
.subQuery()
|
||||
.select('userAlbum.albumId')
|
||||
.from(UserAlbumEntity, 'userAlbum')
|
||||
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||
.getQuery();
|
||||
return `album.id IN ${subQuery}`;
|
||||
});
|
||||
} else {
|
||||
// owned, not shared albums
|
||||
query = query.where('album.ownerId = :ownerId', { ownerId: userId }).andWhere((qb) => {
|
||||
const subQuery = getSharedAlbumIdsSubQuery(qb);
|
||||
return `album.id NOT IN ${subQuery}`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// owned and shared with userId
|
||||
query = query
|
||||
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||
.where('album.ownerId = :ownerId', { ownerId: userId })
|
||||
.orWhere((qb) => {
|
||||
const subQuery = qb
|
||||
.subQuery()
|
||||
.select('userAlbum.albumId')
|
||||
.from(UserAlbumEntity, 'userAlbum')
|
||||
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
|
||||
.getQuery();
|
||||
return `album.id IN ${subQuery}`;
|
||||
});
|
||||
}
|
||||
return query.orderBy('album.createdAt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
||||
const album = await this.albumRepository.findOne({
|
||||
where: { id: albumId },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'],
|
||||
});
|
||||
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
// TODO: sort in query
|
||||
const sortedSharedAsset = album.assets.sort(
|
||||
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
||||
);
|
||||
|
||||
album.assets = sortedSharedAsset;
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
async delete(album: AlbumEntity): Promise<void> {
|
||||
await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId });
|
||||
}
|
||||
|
||||
async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
|
||||
const newRecords: UserAlbumEntity[] = [];
|
||||
|
||||
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
||||
const newEntity = new UserAlbumEntity();
|
||||
newEntity.albumId = album.id;
|
||||
newEntity.sharedUserId = sharedUserId;
|
||||
|
||||
newRecords.push(newEntity);
|
||||
}
|
||||
|
||||
await this.userAlbumRepository.save([...newRecords]);
|
||||
return this.get(album.id);
|
||||
}
|
||||
|
||||
async removeUser(album: AlbumEntity, userId: string): Promise<void> {
|
||||
await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
|
||||
}
|
||||
|
||||
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<boolean> {
|
||||
let deleteAssetCount = 0;
|
||||
// TODO: should probably do a single delete query?
|
||||
for (const assetId of removeAssetsDto.assetIds) {
|
||||
const res = await this.assetAlbumRepository.delete({ albumId: album.id, assetId: assetId });
|
||||
if (res.affected == 1) deleteAssetCount++;
|
||||
}
|
||||
|
||||
// TODO: No need to return boolean if using a singe delete query
|
||||
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
||||
}
|
||||
|
||||
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
|
||||
const newRecords: AssetAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of addAssetsDto.assetIds) {
|
||||
const newAssetAlbum = new AssetAlbumEntity();
|
||||
newAssetAlbum.assetId = assetId;
|
||||
newAssetAlbum.albumId = album.id;
|
||||
|
||||
newRecords.push(newAssetAlbum);
|
||||
}
|
||||
|
||||
// Add album thumbnail if not exist.
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await this.albumRepository.save(album);
|
||||
}
|
||||
|
||||
await this.assetAlbumRepository.save([...newRecords]);
|
||||
return this.get(album.id);
|
||||
}
|
||||
|
||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
|
||||
album.albumName = updateAlbumDto.albumName;
|
||||
|
||||
return this.albumRepository.save(album);
|
||||
}
|
||||
}
|
105
server/apps/immich/src/api-v1/album/album.controller.ts
Normal file
105
server/apps/immich/src/api-v1/album/album.controller.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
ValidationPipe,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
|
||||
import { AlbumService } from './album.service';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
|
||||
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('album')
|
||||
export class AlbumController {
|
||||
constructor(private readonly albumService: AlbumService) {}
|
||||
|
||||
@Post()
|
||||
async create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
|
||||
return this.albumService.create(authUser, createAlbumDto);
|
||||
}
|
||||
|
||||
@Put('/:albumId/users')
|
||||
async addUsers(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) addUsersDto: AddUsersDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
|
||||
}
|
||||
|
||||
@Put('/:albumId/assets')
|
||||
async addAssets(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) addAssetsDto: AddAssetsDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getAllAlbums(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
|
||||
) {
|
||||
return this.albumService.getAllAlbums(authUser, query);
|
||||
}
|
||||
|
||||
@Get('/:albumId')
|
||||
async getAlbumInfo(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.getAlbumInfo(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId/assets')
|
||||
async removeAssetFromAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId')
|
||||
async deleteAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.deleteAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/:albumId/user/:userId')
|
||||
async removeUserFromAlbum(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
|
||||
) {
|
||||
return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
|
||||
}
|
||||
|
||||
@Patch('/:albumId')
|
||||
async updateAlbumInfo(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
|
||||
@Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
|
||||
) {
|
||||
return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId);
|
||||
}
|
||||
}
|
23
server/apps/immich/src/api-v1/album/album.module.ts
Normal file
23
server/apps/immich/src/api-v1/album/album.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlbumService } from './album.service';
|
||||
import { AlbumController } from './album.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
|
||||
controllers: [AlbumController],
|
||||
providers: [
|
||||
AlbumService,
|
||||
{
|
||||
provide: ALBUM_REPOSITORY,
|
||||
useClass: AlbumRepository,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AlbumModule {}
|
414
server/apps/immich/src/api-v1/album/album.service.spec.ts
Normal file
414
server/apps/immich/src/api-v1/album/album.service.spec.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { AlbumService } from './album.service';
|
||||
import { IAlbumRepository } from './album-repository';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
});
|
||||
const albumId = '0001';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
const sharedAlbumSharedAlsoWithId = '3333';
|
||||
const ownedAlbumSharedWithId = '4444';
|
||||
|
||||
const _getOwnedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = authUser.id;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getOwnedSharedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = authUser.id;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.assets = [];
|
||||
albumEntity.sharedUsers = [
|
||||
{
|
||||
id: '99',
|
||||
albumId,
|
||||
sharedUserId: ownedAlbumSharedWithId,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: ownedAlbumSharedWithId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getSharedWithAuthUserAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = sharedAlbumOwnerId;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.assets = [];
|
||||
albumEntity.sharedUsers = [
|
||||
{
|
||||
id: '99',
|
||||
albumId,
|
||||
sharedUserId: authUser.id,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: authUser.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '98',
|
||||
albumId,
|
||||
sharedUserId: sharedAlbumSharedAlsoWithId,
|
||||
//@ts-expect-error Partial stub
|
||||
albumInfo: {},
|
||||
//@ts-expect-error Partial stub
|
||||
userInfo: {
|
||||
id: sharedAlbumSharedAlsoWithId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getNotOwnedNotSharedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = '5555';
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = 'date';
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
albumRepositoryMock = {
|
||||
addAssets: jest.fn(),
|
||||
addSharedUsers: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
removeAssets: jest.fn(),
|
||||
removeUser: jest.fn(),
|
||||
updateAlbum: jest.fn(),
|
||||
};
|
||||
sut = new AlbumService(albumRepositoryMock);
|
||||
});
|
||||
|
||||
it('creates album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.create.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.create(authUser, {
|
||||
albumName: albumEntity.albumName,
|
||||
});
|
||||
|
||||
expect(result.id).toEqual(albumEntity.id);
|
||||
expect(result.albumName).toEqual(albumEntity.albumName);
|
||||
});
|
||||
|
||||
it('gets list of albums for auth user', async () => {
|
||||
const ownedAlbum = _getOwnedAlbum();
|
||||
const ownedSharedAlbum = _getOwnedSharedAlbum();
|
||||
const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
|
||||
const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
|
||||
|
||||
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
|
||||
|
||||
const result = await sut.getAllAlbums(authUser, {});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toEqual(ownedAlbum.id);
|
||||
expect(result[1].id).toEqual(ownedSharedAlbum.id);
|
||||
expect(result[2].id).toEqual(sharedWithMeAlbum.id);
|
||||
});
|
||||
|
||||
it('gets an owned album', async () => {
|
||||
const ownerId = authUser.id;
|
||||
const albumId = '0001';
|
||||
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const expectedResult: AlbumResponseDto = {
|
||||
albumName: 'name',
|
||||
albumThumbnailAssetId: undefined,
|
||||
createdAt: 'date',
|
||||
id: '0001',
|
||||
ownerId,
|
||||
shared: false,
|
||||
assets: [],
|
||||
sharedUsers: [],
|
||||
};
|
||||
await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('gets a shared album', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.getAlbumInfo(authUser, albumId);
|
||||
expect(result.id).toEqual(albumId);
|
||||
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
|
||||
expect(result.shared).toEqual(true);
|
||||
expect(result.sharedUsers).toHaveLength(2);
|
||||
expect(result.sharedUsers[0].id).toEqual(authUser.id);
|
||||
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
|
||||
});
|
||||
|
||||
it('prevents retrieving an album that is not owned or shared', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
await expect(sut.getAlbumInfo(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws a not found exception if the album is not found', async () => {
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined));
|
||||
await expect(sut.getAlbumInfo(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.deleteAlbum(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.deleteAlbum(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('removes a shared user from an owned album', async () => {
|
||||
const albumEntity = _getOwnedSharedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId);
|
||||
});
|
||||
|
||||
it('prevents removing a shared user from a not owned album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
const userIdToRemove = sharedAlbumSharedAlsoWithId;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes itself from a shared album', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id);
|
||||
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
|
||||
});
|
||||
|
||||
it('removes itself from a shared album using "me" as id', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await sut.removeUserFromAlbum(authUser, albumEntity.id, 'me');
|
||||
expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
|
||||
expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
|
||||
});
|
||||
|
||||
it('prevents removing itself from a owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
const updatedAlbumName = 'new album name';
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.updateAlbum.mockImplementation(() =>
|
||||
Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
|
||||
);
|
||||
|
||||
const result = await sut.updateAlbumTitle(
|
||||
authUser,
|
||||
{
|
||||
albumName: updatedAlbumName,
|
||||
ownerId: 'this is not used and will be removed',
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
expect(result.id).toEqual(albumId);
|
||||
expect(result.albumName).toEqual(updatedAlbumName);
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
|
||||
albumName: updatedAlbumName,
|
||||
ownerId: 'this is not used and will be removed',
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents updating a not owned album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
await expect(
|
||||
sut.updateAlbumTitle(
|
||||
authUser,
|
||||
{
|
||||
albumName: 'new album name',
|
||||
ownerId: 'this is not used and will be removed',
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('adds assets to owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('adds assets to shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
);
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('prevents adding assets to a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
expect(
|
||||
sut.addAssetsToAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('removes assets from owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes assets from shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getOwnedSharedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
await expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumEntity.id,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
assetIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents removing assets from a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
expect(
|
||||
sut.removeAssetsFromAlbum(
|
||||
authUser,
|
||||
{
|
||||
assetIds: ['1'],
|
||||
},
|
||||
albumId,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
113
server/apps/immich/src/api-v1/album/album.service.ts
Normal file
113
server/apps/immich/src/api-v1/album/album.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
|
||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
|
||||
|
||||
private async _getAlbum({
|
||||
authUser,
|
||||
albumId,
|
||||
validateIsOwner = true,
|
||||
}: {
|
||||
authUser: AuthUserDto;
|
||||
albumId: string;
|
||||
validateIsOwner?: boolean;
|
||||
}): Promise<AlbumEntity> {
|
||||
const album = await this._albumRepository.get(albumId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album Not Found');
|
||||
}
|
||||
const isOwner = album.ownerId == authUser.id;
|
||||
|
||||
if (validateIsOwner && !isOwner) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
} else if (!isOwner && !album.sharedUsers.some((user) => user.sharedUserId == authUser.id)) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto);
|
||||
return mapAlbum(albumEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared album, including owned and shared one.
|
||||
* @param authUser AuthUserDto
|
||||
* @returns All Shared Album And Its Members
|
||||
*/
|
||||
async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||
return albums.map((album) => mapAlbum(album));
|
||||
}
|
||||
|
||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
|
||||
async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
await this._albumRepository.delete(album);
|
||||
}
|
||||
|
||||
async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
|
||||
const sharedUserId = userId == 'me' ? authUser.id : userId;
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
if (album.ownerId != authUser.id && authUser.id != sharedUserId) {
|
||||
throw new ForbiddenException('Cannot remove a user from a album that is not owned');
|
||||
}
|
||||
if (album.ownerId == sharedUserId) {
|
||||
throw new BadRequestException('The owner of the album cannot be removed');
|
||||
}
|
||||
await this._albumRepository.removeUser(album, sharedUserId);
|
||||
}
|
||||
|
||||
// async removeUsersFromAlbum() {}
|
||||
|
||||
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise<void> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
await this._albumRepository.removeAssets(album, removeAssetsDto);
|
||||
}
|
||||
|
||||
async addAssetsToAlbum(
|
||||
authUser: AuthUserDto,
|
||||
addAssetsDto: AddAssetsDto,
|
||||
albumId: string,
|
||||
): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
|
||||
async updateAlbumTitle(
|
||||
authUser: AuthUserDto,
|
||||
updateAlbumDto: UpdateAlbumDto,
|
||||
albumId: string,
|
||||
): Promise<AlbumResponseDto> {
|
||||
// TODO: this should not come from request DTO. To be removed from here and DTO
|
||||
// if (authUser.id != updateAlbumDto.ownerId) {
|
||||
// throw new BadRequestException('Unauthorized to change album info');
|
||||
// }
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
|
||||
return mapAlbum(updatedAlbum);
|
||||
}
|
||||
}
|
@@ -1,10 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
sharedUserIds: string[];
|
||||
}
|
12
server/apps/immich/src/api-v1/album/dto/create-album.dto.ts
Normal file
12
server/apps/immich/src/api-v1/album/dto/create-album.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@IsOptional()
|
||||
sharedWithUserIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
assetIds?: string[];
|
||||
}
|
21
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts
Normal file
21
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => {
|
||||
if (value == 'true') {
|
||||
return true;
|
||||
} else if (value == 'false') {
|
||||
return false;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
/**
|
||||
* true: only shared albums
|
||||
* false: only non-shared own albums
|
||||
* undefined: shared and owned albums
|
||||
*/
|
||||
shared?: boolean;
|
||||
}
|
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
@@ -1,9 +1,6 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class UpdateShareAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
@@ -0,0 +1,28 @@
|
||||
import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
|
||||
import { User, mapUser } from '../../user/response-dto/user';
|
||||
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
|
||||
|
||||
export interface AlbumResponseDto {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
albumName: string;
|
||||
createdAt: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
shared: boolean;
|
||||
sharedUsers: User[];
|
||||
assets: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
|
||||
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
sharedUsers,
|
||||
shared: sharedUsers.length > 0,
|
||||
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
|
||||
};
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||
|
||||
export interface AssetResponseDto {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
ownerId: string;
|
||||
deviceId: string;
|
||||
type: AssetType;
|
||||
originalPath: string;
|
||||
resizePath: string | null;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
isFavorite: boolean;
|
||||
mimeType: string | null;
|
||||
duration: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.userId,
|
||||
deviceId: entity.deviceId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
resizePath: entity.resizePath,
|
||||
createdAt: entity.createdAt,
|
||||
modifiedAt: entity.modifiedAt,
|
||||
isFavorite: entity.isFavorite,
|
||||
mimeType: entity.mimeType,
|
||||
duration: entity.duration,
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
};
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
|
||||
export interface ExifResponseDto {
|
||||
id: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
imageName: string | null;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
orientation: string | null;
|
||||
dateTimeOriginal: Date | null;
|
||||
modifyDate: Date | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
focalLength: number | null;
|
||||
iso: number | null;
|
||||
exposureTime: number | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
make: entity.make,
|
||||
model: entity.model,
|
||||
imageName: entity.imageName,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
modifyDate: entity.modifyDate,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
focalLength: entity.focalLength,
|
||||
iso: entity.iso,
|
||||
exposureTime: entity.exposureTime,
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude,
|
||||
city: entity.city,
|
||||
state: entity.state,
|
||||
country: entity.country,
|
||||
};
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||
|
||||
export interface SmartInfoResponseDto {
|
||||
id: string;
|
||||
tags: string[] | null;
|
||||
objects: string[] | null;
|
||||
}
|
||||
|
||||
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
tags: entity.tags,
|
||||
objects: entity.objects,
|
||||
};
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
|
||||
export class CreateSharedAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
sharedWithUserIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
assetIds: string[];
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common';
|
||||
import { SharingService } from './sharing.service';
|
||||
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shared')
|
||||
export class SharingController {
|
||||
constructor(private readonly sharingService: SharingService) {}
|
||||
|
||||
@Post('/createAlbum')
|
||||
async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) {
|
||||
return await this.sharingService.create(authUser, createSharedAlbumDto);
|
||||
}
|
||||
|
||||
@Post('/addUsers')
|
||||
async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) {
|
||||
return await this.sharingService.addUsersToAlbum(addUsersDto);
|
||||
}
|
||||
|
||||
@Post('/addAssets')
|
||||
async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) {
|
||||
return await this.sharingService.addAssetsToAlbum(addAssetsDto);
|
||||
}
|
||||
|
||||
@Get('/allSharedAlbums')
|
||||
async getAllSharedAlbums(@GetAuthUser() authUser) {
|
||||
return await this.sharingService.getAllSharedAlbums(authUser);
|
||||
}
|
||||
|
||||
@Get('/:albumId')
|
||||
async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.getAlbumInfo(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/removeAssets')
|
||||
async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) {
|
||||
console.log('removeAssets');
|
||||
return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto);
|
||||
}
|
||||
|
||||
@Delete('/:albumId')
|
||||
async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.deleteAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/leaveAlbum/:albumId')
|
||||
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.leaveAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Patch('/updateInfo')
|
||||
async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
|
||||
return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SharingService } from './sharing.service';
|
||||
import { SharingController } from './sharing.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
|
||||
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
|
||||
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AssetEntity,
|
||||
UserEntity,
|
||||
SharedAlbumEntity,
|
||||
AssetSharedAlbumEntity,
|
||||
UserSharedAlbumEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [SharingController],
|
||||
providers: [SharingService],
|
||||
})
|
||||
export class SharingModule {}
|
@@ -1,199 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { getConnection, Repository } from 'typeorm';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
|
||||
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
|
||||
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
|
||||
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
|
||||
import _ from 'lodash';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SharingService {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
|
||||
@InjectRepository(SharedAlbumEntity)
|
||||
private sharedAlbumRepository: Repository<SharedAlbumEntity>,
|
||||
|
||||
@InjectRepository(AssetSharedAlbumEntity)
|
||||
private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>,
|
||||
|
||||
@InjectRepository(UserSharedAlbumEntity)
|
||||
private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>,
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) {
|
||||
return await getConnection().transaction(async (transactionalEntityManager) => {
|
||||
// Create album entity
|
||||
const newSharedAlbum = new SharedAlbumEntity();
|
||||
newSharedAlbum.ownerId = authUser.id;
|
||||
newSharedAlbum.albumName = createSharedAlbumDto.albumName;
|
||||
|
||||
const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum);
|
||||
|
||||
// Add shared users
|
||||
for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) {
|
||||
const newSharedUser = new UserSharedAlbumEntity();
|
||||
newSharedUser.albumId = sharedAlbum.id;
|
||||
newSharedUser.sharedUserId = sharedUserId;
|
||||
|
||||
await transactionalEntityManager.save(newSharedUser);
|
||||
}
|
||||
|
||||
// Add shared assets
|
||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of createSharedAlbumDto.assetIds) {
|
||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
||||
newAssetSharedAlbum.assetId = assetId;
|
||||
newAssetSharedAlbum.albumId = sharedAlbum.id;
|
||||
|
||||
newRecords.push(newAssetSharedAlbum);
|
||||
}
|
||||
|
||||
if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await transactionalEntityManager.save(sharedAlbum);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save([...newRecords]);
|
||||
|
||||
return sharedAlbum;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared album, including owned and shared one.
|
||||
* @param authUser AuthUserDto
|
||||
* @returns All Shared Album And Its Members
|
||||
*/
|
||||
async getAllSharedAlbums(authUser: AuthUserDto) {
|
||||
const ownedAlbums = await this.sharedAlbumRepository.find({
|
||||
where: { ownerId: authUser.id },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo'],
|
||||
});
|
||||
|
||||
const isSharedWithAlbums = await this.userSharedAlbumRepository.find({
|
||||
where: {
|
||||
sharedUserId: authUser.id,
|
||||
},
|
||||
relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'],
|
||||
select: ['albumInfo'],
|
||||
});
|
||||
|
||||
return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort(
|
||||
(a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(),
|
||||
);
|
||||
}
|
||||
|
||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string) {
|
||||
const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } });
|
||||
const personShared = await this.userSharedAlbumRepository.findOne({
|
||||
where: { albumId: albumId, sharedUserId: authUser.id },
|
||||
});
|
||||
|
||||
if (!(albumOwner || personShared)) {
|
||||
throw new UnauthorizedException('Unauthorized Album Access');
|
||||
}
|
||||
|
||||
const albumInfo = await this.sharedAlbumRepository.findOne({
|
||||
where: { id: albumId },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'],
|
||||
});
|
||||
|
||||
if (!albumInfo) {
|
||||
throw new NotFoundException('Album Not Found');
|
||||
}
|
||||
const sortedSharedAsset = albumInfo.sharedAssets.sort(
|
||||
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
||||
);
|
||||
|
||||
albumInfo.sharedAssets = sortedSharedAsset;
|
||||
|
||||
return albumInfo;
|
||||
}
|
||||
|
||||
async addUsersToAlbum(addUsersDto: AddUsersDto) {
|
||||
const newRecords: UserSharedAlbumEntity[] = [];
|
||||
|
||||
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
||||
const newEntity = new UserSharedAlbumEntity();
|
||||
newEntity.albumId = addUsersDto.albumId;
|
||||
newEntity.sharedUserId = sharedUserId;
|
||||
|
||||
newRecords.push(newEntity);
|
||||
}
|
||||
|
||||
return await this.userSharedAlbumRepository.save([...newRecords]);
|
||||
}
|
||||
|
||||
async deleteAlbum(authUser: AuthUserDto, albumId: string) {
|
||||
return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id });
|
||||
}
|
||||
|
||||
async leaveAlbum(authUser: AuthUserDto, albumId: string) {
|
||||
return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id });
|
||||
}
|
||||
|
||||
async removeUsersFromAlbum() {}
|
||||
|
||||
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) {
|
||||
let deleteAssetCount = 0;
|
||||
const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId });
|
||||
|
||||
if (album.ownerId != authUser.id) {
|
||||
throw new BadRequestException("You don't have permission to remove assets in this album");
|
||||
}
|
||||
|
||||
for (const assetId of removeAssetsDto.assetIds) {
|
||||
const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId });
|
||||
if (res.affected == 1) deleteAssetCount++;
|
||||
}
|
||||
|
||||
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
||||
}
|
||||
|
||||
async addAssetsToAlbum(addAssetsDto: AddAssetsDto) {
|
||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of addAssetsDto.assetIds) {
|
||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
||||
newAssetSharedAlbum.assetId = assetId;
|
||||
newAssetSharedAlbum.albumId = addAssetsDto.albumId;
|
||||
|
||||
newRecords.push(newAssetSharedAlbum);
|
||||
}
|
||||
|
||||
// Add album thumbnail if not exist.
|
||||
const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId });
|
||||
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await this.sharedAlbumRepository.save(album);
|
||||
}
|
||||
|
||||
return await this.assetSharedAlbumRepository.save([...newRecords]);
|
||||
}
|
||||
|
||||
async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
|
||||
if (authUser.id != updateShareAlbumDto.ownerId) {
|
||||
throw new BadRequestException('Unauthorized to change album info');
|
||||
}
|
||||
|
||||
const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
|
||||
sharedAlbum.albumName = updateShareAlbumDto.albumName;
|
||||
|
||||
return await this.sharedAlbumRepository.save(sharedAlbum);
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ParseMeUUIDPipe extends ParseUUIDPipe {
|
||||
async transform(value: string, metadata: ArgumentMetadata) {
|
||||
if (value == 'me') {
|
||||
return value;
|
||||
}
|
||||
return super.transform(value, metadata);
|
||||
}
|
||||
}
|
@@ -11,7 +11,7 @@ import { BullModule } from '@nestjs/bull';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
|
||||
import { CommunicationModule } from './api-v1/communication/communication.module';
|
||||
import { SharingModule } from './api-v1/sharing/sharing.module';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||
@@ -47,7 +47,7 @@ import { DatabaseModule } from '@app/database';
|
||||
|
||||
CommunicationModule,
|
||||
|
||||
SharingModule,
|
||||
AlbumModule,
|
||||
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
|
161
server/apps/immich/test/album.e2e-spec.ts
Normal file
161
server/apps/immich/test/album.e2e-spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||
import { databaseConfig } from '@app/database/config/database.config';
|
||||
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { UserService } from '../src/api-v1/user/user.service';
|
||||
import { UserModule } from '../src/api-v1/user/user.module';
|
||||
|
||||
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
||||
return request(app.getHttpServer()).post('/album').send(data);
|
||||
}
|
||||
|
||||
describe('Album', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
afterAll(async () => {
|
||||
await clearDb();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AlbumModule, ImmichJwtModule, TypeOrmModule.forRoot(databaseConfig)],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('prevents fetching albums if not auth', async () => {
|
||||
const { status } = await request(app.getHttpServer()).get('/album');
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with auth', () => {
|
||||
let authUser: AuthUserDto;
|
||||
let userService: UserService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [AlbumModule, UserModule, TypeOrmModule.forRoot(databaseConfig)],
|
||||
});
|
||||
authUser = getAuthUser(); // set default auth user
|
||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
userService = app.get(UserService);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe('with empty DB', () => {
|
||||
afterEach(async () => {
|
||||
await clearDb();
|
||||
});
|
||||
|
||||
it('creates an album', async () => {
|
||||
const data: CreateAlbumDto = {
|
||||
albumName: 'first albbum',
|
||||
};
|
||||
const { status, body } = await _createAlbum(app, data);
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: authUser.id,
|
||||
albumName: data.albumName,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with albums in DB', () => {
|
||||
const userOneShared = 'userOneShared';
|
||||
const userOneNotShared = 'userOneNotShared';
|
||||
const userTwoShared = 'userTwoShared';
|
||||
const userTwoNotShared = 'userTwoNotShared';
|
||||
let userOne: AuthUserDto;
|
||||
let userTwo: AuthUserDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
// setup users
|
||||
const result = await Promise.all([
|
||||
userService.createUser({
|
||||
email: 'one@test.com',
|
||||
password: '1234',
|
||||
firstName: 'one',
|
||||
lastName: 'test',
|
||||
}),
|
||||
userService.createUser({
|
||||
email: 'two@test.com',
|
||||
password: '1234',
|
||||
firstName: 'two',
|
||||
lastName: 'test',
|
||||
}),
|
||||
]);
|
||||
userOne = result[0];
|
||||
userTwo = result[1];
|
||||
// add user one albums
|
||||
authUser = userOne;
|
||||
await Promise.all([
|
||||
_createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }),
|
||||
_createAlbum(app, { albumName: userOneNotShared }),
|
||||
]);
|
||||
// add user two albums
|
||||
authUser = userTwo;
|
||||
await Promise.all([
|
||||
_createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }),
|
||||
_createAlbum(app, { albumName: userTwoNotShared }),
|
||||
]);
|
||||
// set user one as authed for next requests
|
||||
authUser = userOne;
|
||||
});
|
||||
|
||||
it('returns the album collection including owned and shared', async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).get('/album');
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
||||
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the album collection filtered by shared', async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).get('/album?shared=true');
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
|
||||
expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the album collection filtered by NOT shared', async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).get('/album?shared=false');
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user