fix(slots): update error messages and validation logic for slot creation and updates, including handling of datetime fields and master status

This commit is contained in:
vchikalkin 2025-08-04 23:06:34 +03:00
parent 3941377e78
commit 3448bff1a2
4 changed files with 450 additions and 40 deletions

View File

@ -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 = {

View File

@ -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,
},

View File

@ -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<typeof GQL.CreateSlotDocument>) {
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<typeof GQL.DeleteSlotDocument>) {
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<typeof GQL.UpdateSlotDocument>) {
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<typeof GQL.UpdateSlotDocument>) {
private async checkCreate(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
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<typeof GQL.UpdateSlotDocument>) {
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<VariablesOf<typeof GQL.GetSlotDocument>, 'documentId'>,
) {
const { customer } = await this._getUser();

View File

@ -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) {