From 3448bff1a2db7268a77f884abbdeee7f979e3458 Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Mon, 4 Aug 2025 23:06:34 +0300 Subject: [PATCH] fix(slots): update error messages and validation logic for slot creation and updates, including handling of datetime fields and master status --- packages/graphql/api/base.ts | 6 +- packages/graphql/api/slots.test.js | 414 ++++++++++++++++++++++++-- packages/graphql/api/slots.ts | 64 +++- packages/utils/src/datetime-format.ts | 6 +- 4 files changed, 450 insertions(+), 40 deletions(-) diff --git a/packages/graphql/api/base.ts b/packages/graphql/api/base.ts index f928e9e..cccb294 100644 --- a/packages/graphql/api/base.ts +++ b/packages/graphql/api/base.ts @@ -2,9 +2,9 @@ import { getClientWithToken } from '../apollo/client'; import * as GQL from '../types'; -const ERRORS = { - CUSTOMER_NOT_FOUND: 'Customer not found', - MISSING_TELEGRAM_ID: 'Missing telegram id', +export const ERRORS = { + CUSTOMER_NOT_FOUND: 'Пользователь не найден', + MISSING_TELEGRAM_ID: 'Не указан Telegram ID', }; type UserProfile = { diff --git a/packages/graphql/api/slots.test.js b/packages/graphql/api/slots.test.js index 9049ee1..cf45b9a 100644 --- a/packages/graphql/api/slots.test.js +++ b/packages/graphql/api/slots.test.js @@ -1,6 +1,8 @@ import { getClientWithToken } from '../apollo/client'; import * as GQL from '../types'; +import { ERRORS as BASE_ERRORS } from './base'; import { ERRORS, SlotsService } from './slots'; +import dayjs from 'dayjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../apollo/client'); @@ -29,9 +31,11 @@ describe('SlotsService', () => { telegramId: 123_456_789, }; + const now = dayjs(); + const mockSlot = { - datetime_end: '2024-01-01T11:00:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'slot-123', master: mockCustomer, orders: [], @@ -59,11 +63,371 @@ describe('SlotsService', () => { vi.restoreAllMocks(); }); + describe('createSlot', () => { + const mockVariables = { + input: { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockMutationResult = { + data: { + createSlot: mockSlot, + }, + errors: undefined, + }; + + const mockMasterCustomer = { + ...mockCustomer, + active: true, + role: 'master', + }; + + const mockGetMasterCustomerResult = { + data: { + customers: [mockMasterCustomer], + }, + }; + + it('should successfully create slot when master is active', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.createSlot({ + input: { + ...mockVariables.input, + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + }, + }); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when master is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [] }, // мастер не найден + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(BASE_ERRORS.CUSTOMER_NOT_FOUND); + }); + + it('should throw error when master is not active', async () => { + const inactiveMaster = { + ...mockMasterCustomer, + active: false, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [inactiveMaster] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_MASTER); + }); + + it('should throw error when master role is not master', async () => { + const nonMasterCustomer = { + ...mockMasterCustomer, + role: 'customer', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [nonMasterCustomer] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_MASTER); + }); + + it('should throw error when datetime_start is missing', async () => { + const variablesWithoutStart = { + input: { + datetime_end: now.add(6, 'hour').toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithoutStart); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START); + }); + + it('should throw error when datetime_end is missing', async () => { + const variablesWithoutEnd = { + input: { + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithoutEnd); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_END); + }); + + it('should throw error when datetime_end is before datetime_start', async () => { + const variablesWithInvalidTime = { + input: { + datetime_end: now.toISOString(), + datetime_start: now.add(6, 'hour').toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithInvalidTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when datetime_end equals datetime_start', async () => { + const variablesWithEqualTime = { + input: { + datetime_end: now.toISOString(), + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithEqualTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when slot is created in the past', async () => { + const yesterday = dayjs().subtract(1, 'day'); + + const variablesWithPastTime = { + input: { + datetime_end: yesterday.add(1, 'hour').toISOString(), + datetime_start: yesterday.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithPastTime); + + await expect(result).rejects.toThrow(ERRORS.PAST_SLOT); + }); + + it('should throw error when time overlaps with other slots', async () => { + const overlappingSlot = { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-456', + master: mockMasterCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [overlappingSlot] }, + }); // есть пересекающиеся слоты + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME); + }); + + it('should allow creation when time does not overlap with other slots', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [] }, + }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should include master documentId in mutation variables', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + await slotsService.createSlot(mockVariables); + + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.CreateSlotDocument, + variables: { + ...mockVariables, + input: { + ...mockVariables.input, + master: mockMasterCustomer.documentId, + }, + }, + }); + }); + }); + describe('updateSlot', () => { const mockVariables = { data: { - datetime_end: '2024-01-01T11:00:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), state: GQL.Enum_Slot_State.Open, }, documentId: 'slot-123', @@ -210,13 +574,13 @@ describe('SlotsService', () => { const result = slotsService.updateSlot(mockVariables); - await expect(result).rejects.toThrow('Customer not found'); + await expect(result).rejects.toThrow(BASE_ERRORS.CUSTOMER_NOT_FOUND); }); it('should throw error when datetime_start is missing', async () => { const variablesWithMissingStart = { data: { - datetime_end: '2024-01-01T11:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), }, documentId: 'slot-123', }; @@ -253,7 +617,7 @@ describe('SlotsService', () => { it('should throw error when datetime_end is missing', async () => { const variablesWithMissingEnd = { data: { - datetime_start: '2024-01-01T10:00:00Z', + datetime_start: now.toISOString(), }, documentId: 'slot-123', }; @@ -290,8 +654,8 @@ describe('SlotsService', () => { it('should throw error when datetime_end is before datetime_start', async () => { const variablesWithInvalidTime = { data: { - datetime_end: '2024-01-01T10:00:00Z', - datetime_start: '2024-01-01T11:00:00Z', + datetime_end: now.toISOString(), + datetime_start: now.add(6, 'hour').toISOString(), }, documentId: 'slot-123', }; @@ -321,8 +685,8 @@ describe('SlotsService', () => { it('should throw error when datetime_end equals datetime_start', async () => { const variablesWithEqualTime = { data: { - datetime_end: '2024-01-01T10:00:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.toISOString(), + datetime_start: now.toISOString(), }, documentId: 'slot-123', }; @@ -354,8 +718,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T10:30:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Scheduled, }, @@ -391,8 +755,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T10:30:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Approved, }, @@ -428,8 +792,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T10:30:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Completed, }, @@ -465,8 +829,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T10:30:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Cancelled, }, @@ -502,8 +866,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T10:30:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Draft, // не запрещенное состояние }, @@ -541,8 +905,8 @@ describe('SlotsService', () => { it('should throw error when time overlaps with other slots', async () => { const overlappingSlot = { - datetime_end: '2024-01-01T11:30:00Z', - datetime_start: '2024-01-01T10:30:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'slot-456', master: mockCustomer, orders: [], @@ -651,8 +1015,8 @@ describe('SlotsService', () => { ...mockSlot, orders: [ { - datetime_end: '2024-01-01T11:00:00Z', - datetime_start: '2024-01-01T10:00:00Z', + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), documentId: 'order-123', state: GQL.Enum_Order_State.Scheduled, }, diff --git a/packages/graphql/api/slots.ts b/packages/graphql/api/slots.ts index d0c682a..12e39ea 100644 --- a/packages/graphql/api/slots.ts +++ b/packages/graphql/api/slots.ts @@ -1,21 +1,23 @@ -/* eslint-disable canonical/id-match */ /* eslint-disable @typescript-eslint/naming-convention */ import { getClientWithToken } from '../apollo/client'; import * as GQL from '../types'; import { BaseService } from './base'; import { ServicesService } from './services'; import { type VariablesOf } from '@graphql-typed-document-node/core'; -import { getMinutes } from '@repo/utils/datetime-format'; +import { getMinutes, isBeforeToday } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; export const ERRORS = { HAS_ORDERS: 'Слот имеет активные заказы', + INACTIVE_MASTER: 'Пользователь не является активным или мастером', INVALID_TIME: 'Некорректное время', + MASTER_NOT_FOUND: 'Мастер не найден', MISSING_DATETIME_END: 'Не указана дата окончания', MISSING_DATETIME_START: 'Не указана дата начала', MISSING_SERVICE_ID: 'Не указана услуга', NO_PERMISSION: 'Нет доступа', OVERLAPPING_TIME: 'Время пересекается с другими слотами', + PAST_SLOT: 'Нельзя создать слот в прошлом', SERVICE_NOT_FOUND: 'Слот не найден', SLOT_NOT_FOUND: 'Слот не найден', }; @@ -29,6 +31,8 @@ const FORBIDDEN_ORDER_STATES: GQL.Enum_Order_State[] = [ export class SlotsService extends BaseService { async createSlot(variables: VariablesOf) { + await this.checkCreate(variables); + const { customer } = await this._getUser(); const { mutate } = await getClientWithToken(); @@ -51,7 +55,7 @@ export class SlotsService extends BaseService { } async deleteSlot(variables: VariablesOf) { - await this.checkPermission(variables); + await this.checkUpdatePermission(variables); const { slot } = await this.getSlot({ documentId: variables.documentId }); @@ -161,8 +165,8 @@ export class SlotsService extends BaseService { } async updateSlot(variables: VariablesOf) { - await this.checkPermission(variables); - await this.checkIsTimeChanging(variables); + await this.checkUpdatePermission(variables); + await this.checkUpdateIsTimeChanging(variables); const { mutate } = await getClientWithToken(); @@ -177,13 +181,54 @@ export class SlotsService extends BaseService { return mutationResult.data; } - private async checkIsTimeChanging(variables: VariablesOf) { + private async checkCreate(variables: VariablesOf) { + const { datetime_end, datetime_start } = variables.input; + + if (!datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); + if (!datetime_end) throw new Error(ERRORS.MISSING_DATETIME_END); + + // Проверка, что мастер существует и активен + const { customer: masterEntity } = await this._getUser(); + + if (!masterEntity) throw new Error(ERRORS.MASTER_NOT_FOUND); + + if (!masterEntity?.active || masterEntity.role !== 'master') { + throw new Error(ERRORS.INACTIVE_MASTER); + } + + // Проверка, что слот не создаётся в прошлом + if (datetime_start && isBeforeToday(datetime_start)) { + throw new Error(ERRORS.PAST_SLOT); + } + + // Проверка валидности времени + if (!datetime_start || !datetime_end) { + throw new Error(ERRORS.INVALID_TIME); + } + + if (new Date(datetime_end) <= new Date(datetime_start)) { + throw new Error(ERRORS.INVALID_TIME); + } + + const overlappingEntities = await this.getSlots({ + filters: { + datetime_end: { gt: datetime_start }, + datetime_start: { lt: datetime_end }, + master: { telegramId: { eq: this._user.telegramId } }, + }, + }); + + if (overlappingEntities?.slots?.length) { + throw new Error(ERRORS.OVERLAPPING_TIME); + } + } + + private async checkUpdateIsTimeChanging(variables: VariablesOf) { const { slot } = await this.getSlot({ documentId: variables.documentId }); if (!slot) throw new Error(ERRORS.SLOT_NOT_FOUND); - const datetime_start = variables.data.datetime_start; - const datetime_end = variables.data.datetime_end; + const { datetime_end, datetime_start } = variables.data; const isTimeChanging = datetime_start || datetime_end; @@ -213,6 +258,7 @@ export class SlotsService extends BaseService { datetime_end: { gt: datetime_start }, datetime_start: { lt: datetime_end }, documentId: { not: { eq: documentId } }, + master: { telegramId: { eq: this._user.telegramId } }, }, }); @@ -221,7 +267,7 @@ export class SlotsService extends BaseService { } } - private async checkPermission( + private async checkUpdatePermission( variables: Pick, 'documentId'>, ) { const { customer } = await this._getUser(); diff --git a/packages/utils/src/datetime-format.ts b/packages/utils/src/datetime-format.ts index 93c75af..73cd711 100644 --- a/packages/utils/src/datetime-format.ts +++ b/packages/utils/src/datetime-format.ts @@ -92,10 +92,10 @@ export function getTimeZoneLabel(tz: string = DEFAULT_TZ): string { } export function isBeforeToday(date: Date | string) { - const nowUtc = dayjs().tz(DEFAULT_TZ); - const inputDateUtc = dayjs(date).tz(DEFAULT_TZ); + const now = dayjs().tz(DEFAULT_TZ); + const inputDate = dayjs(date).tz(DEFAULT_TZ); - return inputDateUtc.isBefore(nowUtc, 'day'); + return inputDate.isBefore(now, 'day'); } export function isTodayOrAfter(date: Date | string) {