test(slots): add comprehensive tests for getAvailableTimeSlots method, including edge cases and error handling
This commit is contained in:
parent
ae9488b2d0
commit
620d2eaff4
@ -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:00–12: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: {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user