test(slots): add comprehensive tests for getAvailableTimeSlots method, including edge cases and error handling

This commit is contained in:
vchikalkin 2025-08-05 13:18:42 +03:00
parent ae9488b2d0
commit 620d2eaff4
2 changed files with 435 additions and 10 deletions

View File

@ -1,10 +1,16 @@
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { ERRORS as BASE_ERRORS } from './base';
import { ServicesService } from './services';
import { ERRORS, SlotsService } from './slots';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
if (!dayjs.prototype.duration) {
dayjs.extend(duration);
}
vi.mock('../apollo/client');
vi.mock('./services');
vi.mock('../config/env', () => {
@ -19,8 +25,12 @@ vi.mock('../config/env', () => {
});
const mockGetClientWithToken = vi.mocked(getClientWithToken);
const mockServicesService = vi.mocked(ServicesService);
describe('SlotsService', () => {
/**
* @type {SlotsService}
*/
let slotsService;
const mockUser = { telegramId: 123_456_789 };
@ -31,7 +41,8 @@ describe('SlotsService', () => {
telegramId: 123_456_789,
};
const now = dayjs();
const now = dayjs().minute(0).second(0).millisecond(0);
vi.setSystemTime(now.toDate());
const mockSlot = {
datetime_end: now.add(6, 'hour').toISOString(),
@ -63,6 +74,418 @@ describe('SlotsService', () => {
vi.restoreAllMocks();
});
describe('getAvailableTimeSlots', () => {
const mockVariables = {
filters: {
datetime_start: now.toISOString(),
},
};
const mockContext = {
service: {
documentId: { eq: 'service-123' },
},
};
const mockService = {
active: true,
documentId: 'service-123',
duration: '01:00:00', // 1 час
master: mockCustomer,
name: 'Test Service',
};
const mockGetServiceResult = {
service: mockService,
};
const mockSlotWithOrders = {
datetime_end: now.add(2, 'hour').toISOString(),
datetime_start: now.toISOString(),
documentId: 'slot-123',
master: mockCustomer,
orders: [
{
datetime_end: now.add(1, 'hour').toISOString(),
datetime_start: now.toISOString(),
documentId: 'order-123',
state: GQL.Enum_Order_State.Scheduled,
},
],
state: GQL.Enum_Slot_State.Open,
};
const mockSlotWithoutOrders = {
datetime_end: now.add(5, 'hour').toISOString(),
datetime_start: now.add(3, 'hour').toISOString(),
documentId: 'slot-124',
master: mockCustomer,
orders: [],
state: GQL.Enum_Slot_State.Open,
};
const mockGetSlotsOrdersResult = {
data: {
slots: [mockSlotWithOrders, mockSlotWithoutOrders],
},
};
it('should successfully get available time slots', async () => {
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve(mockGetSlotsOrdersResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext);
expect(result).resolves.toEqual({
slots: [mockSlotWithOrders, mockSlotWithoutOrders],
times: [
{
slotId: 'slot-123',
time: now.add(1, 'hour').toISOString(), // 11:00
},
{
slotId: 'slot-124',
time: now.add(3, 'hour').toISOString(), // 13:00
},
{
slotId: 'slot-124',
time: now.add(3, 'hour').add(15, 'minute').toISOString(), // 13:15
},
{
slotId: 'slot-124',
time: now.add(3, 'hour').add(30, 'minute').toISOString(), // 13:30
},
{
slotId: 'slot-124',
time: now.add(3, 'hour').add(45, 'minute').toISOString(), // 13:45
},
{
slotId: 'slot-124',
time: now.add(4, 'hour').toISOString(), // 14:00
},
],
});
});
it('should throw error when datetime_start is missing', async () => {
const variablesWithoutStart = {
filters: {},
};
const result = slotsService.getAvailableTimeSlots(variablesWithoutStart, mockContext);
await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START);
});
it('should throw error when service documentId is missing', async () => {
const contextWithoutService = {
service: {},
};
const result = slotsService.getAvailableTimeSlots(mockVariables, contextWithoutService);
await expect(result).rejects.toThrow(ERRORS.MISSING_SERVICE_ID);
});
it('should throw error when service is not found', async () => {
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve(mockGetSlotsOrdersResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService возвращающий null
const mockGetService = vi.fn().mockResolvedValue({
data: { service: null },
});
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext);
await expect(result).rejects.toThrow(ERRORS.SERVICE_NOT_FOUND);
});
it('should filter out times that conflict with orders', async () => {
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve(mockGetSlotsOrdersResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Проверяем, что время конфликтующее с заказом не включено
const conflictingTime = now.toISOString();
const hasConflictingTime = result.times.some((time) => time.time === conflictingTime);
expect(hasConflictingTime).toBe(false);
});
it('should include times from cancelled orders', async () => {
vi.setSystemTime(now.toDate()); // синхронизируем "текущее" время
const slotWithCancelledOrder = {
...mockSlotWithOrders,
orders: [
{
datetime_end: now.add(1, 'hour').toISOString(),
datetime_start: now.toISOString(),
documentId: 'order-123',
state: GQL.Enum_Order_State.Cancelled,
},
],
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
data: { slots: [slotWithCancelledOrder] },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Слот: 10:0012:00, сервис = 1 час, шаг = 15 мин
// Все возможные начала интервалов: от 10:00 до 11:00 включительно (всего 5)
const expectedTimes = [
// now.toISOString(), // 10:00
now.add(15, 'minute').toISOString(), // 10:15
now.add(30, 'minute').toISOString(), // 10:30
now.add(45, 'minute').toISOString(), // 10:45
now.add(1, 'hour').toISOString(), // 11:00
];
const actualTimes = result.times
.filter((time) => time.slotId === slotWithCancelledOrder.documentId)
.map((time) => time.time);
expect(actualTimes).toEqual(expectedTimes);
});
it('should filter out times in the past', async () => {
const pastSlot = {
datetime_end: now.subtract(1, 'hour').toISOString(),
datetime_start: now.subtract(2, 'hour').toISOString(),
documentId: 'slot-past',
master: mockCustomer,
orders: [],
state: GQL.Enum_Slot_State.Open,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
data: { slots: [pastSlot] },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Проверяем, что время в прошлом не включено
expect(result.times).toHaveLength(0);
});
it('should generate times with 15-minute intervals', async () => {
const longSlot = {
datetime_end: now.add(3, 'hour').toISOString(),
datetime_start: now.toISOString(),
documentId: 'slot-long',
master: mockCustomer,
orders: [],
state: GQL.Enum_Slot_State.Open,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
data: { slots: [longSlot] },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Проверяем, что времена генерируются с интервалом 15 минут
expect(result.times.length).toBeGreaterThan(1);
for (let index = 1; index < result.times.length; index++) {
const currentTime = dayjs(result.times[index].time);
const previousTime = dayjs(result.times[index - 1].time);
const diff = currentTime.diff(previousTime, 'minute');
expect(diff).toBe(15);
}
});
it('should skip slots without datetime_start or datetime_end', async () => {
const incompleteSlot = {
datetime_end: now.add(1, 'hour').toISOString(),
datetime_start: null, // отсутствует datetime_start
documentId: 'slot-incomplete',
master: mockCustomer,
orders: [],
state: GQL.Enum_Slot_State.Open,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
data: { slots: [incompleteSlot] },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService
const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult);
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Проверяем, что слот без datetime_start пропущен
expect(result.times).toHaveLength(0);
});
it('should handle GraphQL errors', async () => {
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
error: { message: 'GraphQL error' },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext);
await expect(result).rejects.toThrow('GraphQL error');
});
it('should calculate service duration correctly', async () => {
const serviceWithDuration = {
...mockService,
duration: '00:30:00', // 30 минут
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetSlotsOrdersDocument) {
return Promise.resolve({
data: { slots: [mockSlotWithoutOrders] },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
// Мокаем ServicesService.getService с другой длительностью
const mockGetService = vi.fn().mockResolvedValue({
service: serviceWithDuration,
});
mockServicesService.mockImplementation(() => ({
getService: mockGetService,
}));
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
// Проверяем, что времена генерируются с учетом длительности услуги
expect(result.times.length).toBeGreaterThan(0);
// Последнее время должно быть не позже чем datetime_end минус длительность услуги
const lastTime = dayjs(result.times[result.times.length - 1].time);
const slotEnd = dayjs(mockSlotWithoutOrders.datetime_end);
const serviceDuration = dayjs.duration('00:30:00');
const maxTime = slotEnd.subtract(serviceDuration);
expect(lastTime.valueOf()).toBeLessThanOrEqual(maxTime.valueOf());
});
});
describe('createSlot', () => {
const mockVariables = {
input: {

View File

@ -18,17 +18,10 @@ export const ERRORS = {
NO_PERMISSION: 'Нет доступа',
OVERLAPPING_TIME: 'Время пересекается с другими слотами',
PAST_SLOT: 'Нельзя создать слот в прошлом',
SERVICE_NOT_FOUND: 'Слот не найден',
SERVICE_NOT_FOUND: 'Сервис не найден',
SLOT_NOT_FOUND: 'Слот не найден',
};
const FORBIDDEN_ORDER_STATES: GQL.Enum_Order_State[] = [
GQL.Enum_Order_State.Scheduled,
GQL.Enum_Order_State.Approved,
GQL.Enum_Order_State.Completed,
GQL.Enum_Order_State.Cancelled,
];
export class SlotsService extends BaseService {
async createSlot(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
await this.checkCreate(variables);
@ -246,7 +239,16 @@ export class SlotsService extends BaseService {
if (
orders?.length &&
orders?.some((order) => order?.state && FORBIDDEN_ORDER_STATES.includes(order.state))
orders?.some(
(order) =>
order?.state &&
[
GQL.Enum_Order_State.Approved,
GQL.Enum_Order_State.Cancelled,
GQL.Enum_Order_State.Completed,
GQL.Enum_Order_State.Scheduled,
].includes(order.state),
)
) {
throw new Error(ERRORS.HAS_ORDERS);
}