calendar due date
Gitea Actions Demo / Deploy (push) Successful in 31s

This commit is contained in:
vadim committed 2026-10-12 11:08:27 +03:00
1 parent cb29c7d2c4
commit 7aecc6f96e
32 files changed
+532 -25

No files matched your search

@@ -0,0 +1,7 @@
import { IssueWithUsersDto } from './issue-with-users.dto.js';
export class CalendarIssuesDto {
list: IssueWithUsersDto[];
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
truncated: boolean;
}
@@ -20,6 +20,7 @@ describe('CreateIssueUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -32,6 +32,7 @@ describe('DeleteIssueUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -33,6 +33,7 @@ describe('GetIssueByCkeyUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -33,6 +33,7 @@ describe('GetIssueUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -0,0 +1,10 @@
export class ListCalendarIssuesQuery {
constructor(
public readonly userId: string,
public readonly from: Date,
public readonly to: Date,
public readonly projectId?: string,
public readonly status?: string[],
public readonly assigneeId?: string,
) {}
}
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js';
import { InvalidCalendarRangeException } from '../../../domain/exceptions/invalid-calendar-range.exception.js';
import { ListCalendarIssuesUseCase } from './list-calendar-issues.use-case.js';
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
const FROM = new Date('2026-09-28T00:00:00+03:00');
const TO = new Date('2026-11-09T00:00:00+03:00');
const buildIssue = (id: number) =>
Issue.reconstitute({
id,
key: `ISSUE-${id}`,
title: `Issue ${id}`,
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
projectId: 'project-1',
dueDate: new Date('2026-10-15T12:00:00Z'),
createdAt: new Date(),
updatedAt: new Date(),
});
describe('ListCalendarIssuesUseCase', () => {
let repository: IIssueRepository;
let issueUserExpander: IssueUserExpanderService;
let useCase: ListCalendarIssuesUseCase;
beforeEach(() => {
repository = {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
issueUserExpander = {
expandAll: vi.fn(async (issues: Issue[]) => issues.map((issue) => ({ id: issue.id }) as IssueWithUsersDto)),
} as unknown as IssueUserExpanderService;
useCase = new ListCalendarIssuesUseCase(repository, issueUserExpander);
});
it('forwards the range and filters, asking for one issue more than the limit', async () => {
vi.mocked(repository.findByDueDateRange).mockResolvedValue([buildIssue(1), buildIssue(2)]);
const result = await useCase.execute(
new ListCalendarIssuesQuery('user-1', FROM, TO, 'project-1', ['OPEN'], 'user-2'),
);
expect(repository.findByDueDateRange).toHaveBeenCalledWith({
from: FROM,
to: TO,
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
userId: 'user-1',
projectId: 'project-1',
status: ['OPEN'],
assigneeId: 'user-2',
});
expect(result).toEqual({ list: [{ id: 1 }, { id: 2 }], truncated: false });
});
it('cuts the list to the limit and marks it as truncated', async () => {
const issues = Array.from({ length: ListCalendarIssuesUseCase.MAX_ISSUES + 1 }, (_, index) =>
buildIssue(index + 1),
);
vi.mocked(repository.findByDueDateRange).mockResolvedValue(issues);
const result = await useCase.execute(new ListCalendarIssuesQuery('user-1', FROM, TO));
expect(result.truncated).toBe(true);
expect(result.list).toHaveLength(ListCalendarIssuesUseCase.MAX_ISSUES);
});
it('rejects an invalid range without querying the repository', async () => {
await expect(useCase.execute(new ListCalendarIssuesQuery('user-1', TO, FROM))).rejects.toThrow(
InvalidCalendarRangeException,
);
expect(repository.findByDueDateRange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,42 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { CalendarRange } from '../../../domain/value-objects/calendar-range.vo.js';
import { CalendarIssuesDto } from '../../dto/calendar-issues.dto.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
/**
* Задачи для календаря: все задачи с dueDate в [from, to) без пагинации,
* но не больше MAX_ISSUES — иначе ответ помечается как truncated.
* Задачи без dueDate в календарь не попадают.
*/
@Injectable()
export class ListCalendarIssuesUseCase {
static readonly MAX_ISSUES = 500;
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly issueUserExpander: IssueUserExpanderService,
) {}
async execute(query: ListCalendarIssuesQuery): Promise<CalendarIssuesDto> {
const range = CalendarRange.create({ from: query.from, to: query.to });
// Берём на одну больше лимита, чтобы узнать, есть ли что-то за ним
const issues = await this.issueRepository.findByDueDateRange({
from: range.from,
to: range.to,
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
userId: query.userId,
projectId: query.projectId,
status: query.status,
assigneeId: query.assigneeId,
});
const truncated = issues.length > ListCalendarIssuesUseCase.MAX_ISSUES;
const list = truncated ? issues.slice(0, ListCalendarIssuesUseCase.MAX_ISSUES) : issues;
return { list: await this.issueUserExpander.expandAll(list), truncated };
}
}
@@ -7,5 +7,6 @@ export class ListIssuesQuery {
public readonly status?: string[],
public readonly text?: string,
public readonly assigneeId?: string,
public readonly hasDueDate?: boolean,
) {}
}
@@ -32,6 +32,7 @@ describe('ListIssuesUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -61,6 +62,20 @@ describe('ListIssuesUseCase', () => {
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
});
it('forwards the hasDueDate filter', async () => {
vi.mocked(repository.findAll).mockResolvedValue({
list: [],
meta: { totalCount: 0, page: 1, totalPages: 0 },
});
vi.mocked(issueUserExpander.expandAll).mockResolvedValue([]);
await useCase.execute(new ListIssuesQuery(20, 1, 'user-1', 'project-1', undefined, undefined, undefined, false));
expect(repository.findAll).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'project-1', hasDueDate: false }),
);
});
it('returns an empty array when there are no issues', async () => {
vi.mocked(repository.findAll).mockResolvedValue({
list: [],
@@ -22,6 +22,7 @@ export class ListIssuesUseCase {
status: query.status,
text: query.text,
assigneeId: query.assigneeId,
hasDueDate: query.hasDueDate,
});
return { list: await this.issueUserExpander.expandAll(list), meta };
@@ -37,6 +37,7 @@ describe('UpdateIssueUseCase', () => {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),