1 parent
f085517732
commit
46903e9ec5
17 files changed
+366
-6
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js';
|
||||
import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js';
|
||||
import { UserAvatarFileOwnerService } from './user-avatar-file-owner.service.js';
|
||||
import { mockUserRepository } from '../use-cases/test-helpers.js';
|
||||
|
||||
describe('UserAvatarFileOwnerService', () => {
|
||||
let userRepository: IUserRepository;
|
||||
let registry: FileOwnerRegistry;
|
||||
let owner: UserAvatarFileOwnerService;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = mockUserRepository();
|
||||
registry = new FileOwnerRegistry();
|
||||
owner = new UserAvatarFileOwnerService(userRepository, registry);
|
||||
owner.onModuleInit();
|
||||
});
|
||||
|
||||
it('registers itself, so GC keeps avatars', async () => {
|
||||
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['a']);
|
||||
|
||||
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
|
||||
});
|
||||
|
||||
it('makes an avatar visible to any user', async () => {
|
||||
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['file-1']);
|
||||
|
||||
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
|
||||
expect(userRepository.findAvatarFileIds).toHaveBeenCalledWith(['file-1']);
|
||||
});
|
||||
|
||||
it('hides a file that is nobody’s avatar', async () => {
|
||||
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue([]);
|
||||
|
||||
expect(await owner.isVisibleThrough('file-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository.interface.js';
|
||||
import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js';
|
||||
|
||||
/**
|
||||
* Регистрирует аватары как владельца файлов: без этого GC file-модуля
|
||||
* удалял бы их как сирот, а другие пользователи не видели бы чужие аватары.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserAvatarFileOwnerService implements FileOwner, OnModuleInit {
|
||||
readonly name = 'user-avatar';
|
||||
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly fileOwners: FileOwnerRegistry,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.fileOwners.register(this);
|
||||
}
|
||||
|
||||
findReferenced(fileIds: string[]): Promise<string[]> {
|
||||
return this.userRepository.findAvatarFileIds(fileIds);
|
||||
}
|
||||
|
||||
/** Аватары показываются рядом с задачами и комментариями, поэтому видны всем пользователям */
|
||||
async isVisibleThrough(fileId: string): Promise<boolean> {
|
||||
const referenced = await this.userRepository.findAvatarFileIds([fileId]);
|
||||
return referenced.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class RemoveAvatarCommand {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
|
||||
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
|
||||
import { RemoveAvatarUseCase } from './remove-avatar.use-case.js';
|
||||
import { RemoveAvatarCommand } from './remove-avatar.command.js';
|
||||
import { buildUser, mockUserRepository } from '../test-helpers.js';
|
||||
|
||||
describe('RemoveAvatarUseCase', () => {
|
||||
let userRepository: IUserRepository;
|
||||
let useCase: RemoveAvatarUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = mockUserRepository();
|
||||
useCase = new RemoveAvatarUseCase(userRepository);
|
||||
});
|
||||
|
||||
it('clears the avatar', async () => {
|
||||
vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser());
|
||||
|
||||
const dto = await useCase.execute(new RemoveAvatarCommand('user-1'));
|
||||
|
||||
expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', null);
|
||||
expect(dto.image).toBeNull();
|
||||
});
|
||||
|
||||
it('throws UserNotFoundException when the user is gone', async () => {
|
||||
vi.mocked(userRepository.setAvatar).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new RemoveAvatarCommand('user-1'))).rejects.toThrow(UserNotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
|
||||
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
|
||||
import { RemoveAvatarCommand } from './remove-avatar.command.js';
|
||||
import { UserDto } from '../../dto/user.dto.js';
|
||||
import { UserApplicationMapper } from '../../mappers/user-application.mapper.js';
|
||||
|
||||
/** Снимает аватар; сам файл, оставшийся без владельца, удалит GC file-модуля */
|
||||
@Injectable()
|
||||
export class RemoveAvatarUseCase {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RemoveAvatarCommand): Promise<UserDto> {
|
||||
const user = await this.userRepository.setAvatar(command.userId, null);
|
||||
if (!user) {
|
||||
throw new UserNotFoundException(command.userId);
|
||||
}
|
||||
|
||||
return UserApplicationMapper.toDto(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class SetAvatarCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly fileId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
|
||||
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
|
||||
import { buildFile } from '../../../../file/application/use-cases/test-helpers.js';
|
||||
import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js';
|
||||
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
|
||||
import { SetAvatarUseCase } from './set-avatar.use-case.js';
|
||||
import { SetAvatarCommand } from './set-avatar.command.js';
|
||||
import { buildUser, mockUserRepository } from '../test-helpers.js';
|
||||
|
||||
describe('SetAvatarUseCase', () => {
|
||||
let userRepository: IUserRepository;
|
||||
let fileAccess: FileAccessService;
|
||||
let useCase: SetAvatarUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = mockUserRepository();
|
||||
fileAccess = { getVisibleFile: vi.fn() } as unknown as FileAccessService;
|
||||
useCase = new SetAvatarUseCase(userRepository, fileAccess);
|
||||
|
||||
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile());
|
||||
vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser({ image: '/api/files/file-1/download' }));
|
||||
});
|
||||
|
||||
it('sets an own image as the avatar', async () => {
|
||||
const dto = await useCase.execute(new SetAvatarCommand('user-1', 'file-1'));
|
||||
|
||||
expect(fileAccess.getVisibleFile).toHaveBeenCalledWith('file-1', 'user-1');
|
||||
expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', {
|
||||
fileId: 'file-1',
|
||||
image: '/api/files/file-1/download',
|
||||
});
|
||||
expect(dto.image).toBe('/api/files/file-1/download');
|
||||
});
|
||||
|
||||
it('rejects a file uploaded by someone else', async () => {
|
||||
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ uploaderId: 'user-2' }));
|
||||
|
||||
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileForbiddenException);
|
||||
expect(userRepository.setAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a file that is not an inline image', async () => {
|
||||
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ mimeType: 'image/svg+xml' }));
|
||||
|
||||
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(AvatarNotImageException);
|
||||
expect(userRepository.setAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set an invisible file', async () => {
|
||||
vi.mocked(fileAccess.getVisibleFile).mockRejectedValue(new FileNotFoundException('file-1'));
|
||||
|
||||
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileNotFoundException);
|
||||
expect(userRepository.setAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UserNotFoundException when the user is gone', async () => {
|
||||
vi.mocked(userRepository.setAvatar).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(UserNotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
|
||||
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
|
||||
import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js';
|
||||
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||
import { SetAvatarCommand } from './set-avatar.command.js';
|
||||
import { UserDto } from '../../dto/user.dto.js';
|
||||
import { UserApplicationMapper } from '../../mappers/user-application.mapper.js';
|
||||
|
||||
/** URL, по которому клиент показывает аватар; доступ проверяет GET /files/:id/download */
|
||||
export const avatarUrl = (fileId: string) => `/api/files/${fileId}/download`;
|
||||
|
||||
/**
|
||||
* Делает аватаром файл, заранее загруженный через POST /files.
|
||||
* Только свой файл — аватар виден всем, иначе так можно было бы раскрыть
|
||||
* чужое вложение. И только картинку, которую браузер покажет inline.
|
||||
*
|
||||
* Прежний аватар остаётся без владельца, его удалит GC file-модуля.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SetAvatarUseCase {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly fileAccess: FileAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: SetAvatarCommand): Promise<UserDto> {
|
||||
const file = await this.fileAccess.getVisibleFile(command.fileId, command.userId);
|
||||
file.assertOwnedBy(command.userId);
|
||||
if (!file.isInlineViewable) {
|
||||
throw new AvatarNotImageException(command.fileId);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.setAvatar(command.userId, {
|
||||
fileId: command.fileId,
|
||||
image: avatarUrl(command.fileId),
|
||||
});
|
||||
if (!user) {
|
||||
throw new UserNotFoundException(command.userId);
|
||||
}
|
||||
|
||||
return UserApplicationMapper.toDto(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js';
|
||||
import { User } from '../../domain/entities/user.entity.js';
|
||||
|
||||
export const buildUser = (overrides: Partial<{ id: string; image: string | null }> = {}) =>
|
||||
User.reconstitute({
|
||||
id: overrides.id ?? 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
emailVerified: true,
|
||||
image: overrides.image === undefined ? null : overrides.image,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
export const mockUserRepository = (): IUserRepository => ({
|
||||
findById: vi.fn(),
|
||||
findByIds: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
setAvatar: vi.fn(),
|
||||
findAvatarFileIds: vi.fn(),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export class AvatarNotImageException extends Error {
|
||||
constructor(fileId: string) {
|
||||
super(`Файл id=${fileId} нельзя сделать аватаром: нужна картинка PNG, JPEG, GIF или WebP`);
|
||||
this.name = 'AvatarNotImageException';
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,8 @@ export interface IUserRepository {
|
||||
findById(id: string): Promise<User | null>;
|
||||
findByIds(ids: string[]): Promise<User[]>;
|
||||
findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>;
|
||||
/** null снимает аватар. Возвращает null, если пользователя нет */
|
||||
setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null>;
|
||||
/** Подмножество fileIds, которые являются чьим-то аватаром */
|
||||
findAvatarFileIds(fileIds: string[]): Promise<string[]>;
|
||||
}
|
||||
+18
@@ -38,4 +38,22 @@ export class UserPrismaRepository implements IUserRepository {
|
||||
meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
async setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null> {
|
||||
const { count } = await this.prisma.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { avatarFileId: avatar?.fileId ?? null, image: avatar?.image ?? null },
|
||||
});
|
||||
return count > 0 ? this.findById(userId) : null;
|
||||
}
|
||||
|
||||
async findAvatarFileIds(fileIds: string[]): Promise<string[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
|
||||
const records = await this.prisma.user.findMany({
|
||||
where: { avatarFileId: { in: fileIds } },
|
||||
select: { avatarFileId: true },
|
||||
});
|
||||
return records.map((record) => record.avatarFileId as string);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AllowAnonymous, OptionalAuth, Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||
import { GetUserUseCase } from '../../application/use-cases/get-user/get-user.use-case.js';
|
||||
import { ListUsersUseCase } from '../../application/use-cases/list-users/list-users.use-case.js';
|
||||
@@ -6,12 +17,22 @@ import { GetUserQuery } from '../../application/use-cases/get-user/get-user.quer
|
||||
import { ListUsersQuery } from '../../application/use-cases/list-users/list-users.query.js';
|
||||
import { FilterUserDto } from '../dto/filter-user.dto.js';
|
||||
import { UserNotFoundException } from '../../domain/exceptions/user-not-found.exception.js';
|
||||
import { AvatarNotImageException } from '../../domain/exceptions/avatar-not-image.exception.js';
|
||||
import { SetAvatarUseCase } from '../../application/use-cases/set-avatar/set-avatar.use-case.js';
|
||||
import { RemoveAvatarUseCase } from '../../application/use-cases/remove-avatar/remove-avatar.use-case.js';
|
||||
import { SetAvatarCommand } from '../../application/use-cases/set-avatar/set-avatar.command.js';
|
||||
import { RemoveAvatarCommand } from '../../application/use-cases/remove-avatar/remove-avatar.command.js';
|
||||
import { SetAvatarRequestDto } from '../dto/set-avatar.dto.js';
|
||||
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
|
||||
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly getUserUseCase: GetUserUseCase,
|
||||
private readonly listUsersUseCase: ListUsersUseCase,
|
||||
private readonly setAvatarUseCase: SetAvatarUseCase,
|
||||
private readonly removeAvatarUseCase: RemoveAvatarUseCase,
|
||||
) {}
|
||||
|
||||
@Get('me')
|
||||
@@ -19,6 +40,17 @@ export class UserController {
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Картинка загружается отдельно (POST /files), здесь — только привязка к пользователю */
|
||||
@Put('me/avatar')
|
||||
setAvatar(@Body() dto: SetAvatarRequestDto, @Session() session: UserSession) {
|
||||
return this.handleNotFound(() => this.setAvatarUseCase.execute(new SetAvatarCommand(session.user.id, dto.fileId)));
|
||||
}
|
||||
|
||||
@Delete('me/avatar')
|
||||
removeAvatar(@Session() session: UserSession) {
|
||||
return this.handleNotFound(() => this.removeAvatarUseCase.execute(new RemoveAvatarCommand(session.user.id)));
|
||||
}
|
||||
|
||||
@Get('public')
|
||||
@AllowAnonymous() // Allow anonymous access (no authentication required)
|
||||
async publicRoute() {
|
||||
@@ -41,14 +73,20 @@ export class UserController {
|
||||
return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id)));
|
||||
}
|
||||
|
||||
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */
|
||||
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
|
||||
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof UserNotFoundException) {
|
||||
if (error instanceof UserNotFoundException || error instanceof FileNotFoundException) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
if (error instanceof FileForbiddenException) {
|
||||
throw new ForbiddenException(error.message);
|
||||
}
|
||||
if (error instanceof AvatarNotImageException) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SetAvatarRequestDto {
|
||||
/** id картинки, загруженной через POST /files */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
fileId: string;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||
import { FileModule } from '../file/file.module.js';
|
||||
import { UserPrismaRepository } from './infrastructure/persistence/prisma/repositories/user.prisma-repository.js';
|
||||
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js';
|
||||
import { UserController } from './presentation/controllers/user.controller.js';
|
||||
@@ -7,14 +8,20 @@ import { GetUserUseCase } from './application/use-cases/get-user/get-user.use-ca
|
||||
import { GetUsersUseCase } from './application/use-cases/get-users/get-users.use-case.js';
|
||||
import { ListUsersUseCase } from './application/use-cases/list-users/list-users.use-case.js';
|
||||
import { UserResolver } from './presentation/resolvers/user.resolver.js';
|
||||
import { SetAvatarUseCase } from './application/use-cases/set-avatar/set-avatar.use-case.js';
|
||||
import { RemoveAvatarUseCase } from './application/use-cases/remove-avatar/remove-avatar.use-case.js';
|
||||
import { UserAvatarFileOwnerService } from './application/services/user-avatar-file-owner.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, FileModule],
|
||||
controllers: [UserController],
|
||||
providers: [
|
||||
GetUserUseCase,
|
||||
GetUsersUseCase,
|
||||
ListUsersUseCase,
|
||||
SetAvatarUseCase,
|
||||
RemoveAvatarUseCase,
|
||||
UserAvatarFileOwnerService,
|
||||
UserResolver,
|
||||
{
|
||||
// Domain видит только интерфейс IUserRepository,
|
||||
|
||||
Reference in new issue
Block a user