commit
7b999a670c
243 files changed
+24818
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { IssueDto } from '../dto/issue.dto.js';
|
||||
|
||||
/**
|
||||
* In-process шина событий issue-модуля для SSE-подписчиков.
|
||||
* Живёт как синглтон-провайдер, поэтому все запросы в рамках
|
||||
* одного инстанса приложения получают одни и те же события.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueEventsService {
|
||||
private readonly issueCreated$ = new Subject<IssueDto>();
|
||||
private readonly issueUpdated$ = new Subject<IssueDto>();
|
||||
private readonly issueDeleted$ = new Subject<IssueDto>();
|
||||
|
||||
emitIssueCreated(issue: IssueDto): void {
|
||||
this.issueCreated$.next(issue);
|
||||
}
|
||||
|
||||
onIssueCreated(): Observable<IssueDto> {
|
||||
return this.issueCreated$.asObservable();
|
||||
}
|
||||
|
||||
emitIssueUpdated(issue: IssueDto): void {
|
||||
this.issueUpdated$.next(issue);
|
||||
}
|
||||
|
||||
onIssueUpdated(): Observable<IssueDto> {
|
||||
return this.issueUpdated$.asObservable();
|
||||
}
|
||||
|
||||
emitIssueDeleted(issue: IssueDto): void {
|
||||
this.issueDeleted$.next(issue);
|
||||
}
|
||||
|
||||
onIssueDeleted(): Observable<IssueDto> {
|
||||
return this.issueDeleted$.asObservable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueUserExpanderService } from './issue-user-expander.service.js';
|
||||
import type { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import type { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
|
||||
const FIXED_DATE = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
const buildUser = (id: string): UserDto => ({
|
||||
id,
|
||||
name: id,
|
||||
email: `${id}@example.com`,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: FIXED_DATE,
|
||||
updatedAt: FIXED_DATE,
|
||||
});
|
||||
|
||||
const buildIssue = (overrides: Partial<{ assignee: string | null; reporter: string }> = {}) =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Fix login bug',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2',
|
||||
reporter: overrides.reporter ?? 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('IssueUserExpanderService', () => {
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let service: IssueUserExpanderService;
|
||||
|
||||
beforeEach(() => {
|
||||
getUsersUseCase = { execute: vi.fn() } as unknown as GetUsersUseCase;
|
||||
service = new IssueUserExpanderService(getUsersUseCase);
|
||||
});
|
||||
|
||||
it('resolves assignee and reporter to full users', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1'), buildUser('user-2')]);
|
||||
|
||||
const dto = await service.expand(buildIssue());
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1', 'user-2'] }));
|
||||
expect(dto.reporterId).toBe('user-1');
|
||||
expect(dto.assigneeId).toBe('user-2');
|
||||
expect(dto.reporter).toEqual(buildUser('user-1'));
|
||||
expect(dto.assignee).toEqual(buildUser('user-2'));
|
||||
});
|
||||
|
||||
it('resolves assignee to null when the issue has no assignee', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1')]);
|
||||
|
||||
const dto = await service.expand(buildIssue({ assignee: null }));
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1'] }));
|
||||
expect(dto.assignee).toBeNull();
|
||||
});
|
||||
|
||||
it('requests each user id only once when reporter and assignee are the same person', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1')]);
|
||||
|
||||
await service.expand(buildIssue({ assignee: 'user-1', reporter: 'user-1' }));
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1'] }));
|
||||
});
|
||||
|
||||
describe('expandAll', () => {
|
||||
it('resolves assignee/reporter for every issue in a single batched lookup', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1'), buildUser('user-2')]);
|
||||
|
||||
const [first, second] = await service.expandAll([
|
||||
buildIssue({ assignee: 'user-2', reporter: 'user-1' }),
|
||||
buildIssue({ assignee: null, reporter: 'user-1' }),
|
||||
]);
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledTimes(1);
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1', 'user-2'] }));
|
||||
expect(first!.assignee).toEqual(buildUser('user-2'));
|
||||
expect(second!.assignee).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty array without calling GetUsersUseCase when there are no issues', async () => {
|
||||
const result = await service.expandAll([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getUsersUseCase.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
|
||||
import { IssueApplicationMapper } from '../mappers/issue-application.mapper.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
|
||||
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */
|
||||
@Injectable()
|
||||
export class IssueUserExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
|
||||
async expand(issue: Issue): Promise<IssueWithUsersDto> {
|
||||
const [dto] = await this.expandAll([issue]);
|
||||
return dto!;
|
||||
}
|
||||
|
||||
async expandAll(issues: Issue[]): Promise<IssueWithUsersDto[]> {
|
||||
if (issues.length === 0) return [];
|
||||
|
||||
const userIds = [
|
||||
...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
|
||||
return issues.map((issue) => IssueApplicationMapper.toDtoWithUsers(issue, usersById));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user