From 620d2eaff4d0f116158108667d410c6e010dff5e Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Tue, 5 Aug 2025 13:18:42 +0300 Subject: [PATCH] test(slots): add comprehensive tests for getAvailableTimeSlots method, including edge cases and error handling --- packages/graphql/api/slots.test.js | 425 ++++++++++++++++++++++++++++- packages/graphql/api/slots.ts | 20 +- 2 files changed, 435 insertions(+), 10 deletions(-) diff --git a/packages/graphql/api/slots.test.js b/packages/graphql/api/slots.test.js index cf45b9a..543f4c4 100644 --- a/packages/graphql/api/slots.test.js +++ b/packages/graphql/api/slots.test.js @@ -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: { diff --git a/packages/graphql/api/slots.ts b/packages/graphql/api/slots.ts index f3a6a2f..d6cad8f 100644 --- a/packages/graphql/api/slots.ts +++ b/packages/graphql/api/slots.ts @@ -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) { 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); }