fix(slots): update error handling for customer and slot retrieval, enhance time validation in slot updates

This commit is contained in:
vchikalkin 2025-08-04 22:00:26 +03:00
parent 5ec324d207
commit 6d86c0d2db
3 changed files with 640 additions and 91 deletions

View File

@ -3,8 +3,8 @@ import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
const ERRORS = {
CUSTOMER_NOT_FOUND: 'Customer not found',
MISSING_TELEGRAM_ID: 'Missing telegram id',
NOT_FOUND_CUSTOMER: 'Customer not found',
};
type UserProfile = {
@ -32,7 +32,7 @@ export class BaseService {
const customer = result.data.customers.at(0);
if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER);
if (!customer) throw new Error(ERRORS.CUSTOMER_NOT_FOUND);
return { customer };
}

View File

@ -78,10 +78,21 @@ describe('SlotsService', () => {
it('should successfully update slot when user has permission', async () => {
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValueOnce(mockGetSlotResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({ data: { slots: [] } });
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
@ -93,20 +104,58 @@ describe('SlotsService', () => {
await expect(result).resolves.toBe(mockMutationResult.data);
});
it('should successfully update slot when time is not changing', async () => {
const variablesWithoutTime = {
data: {
state: GQL.Enum_Slot_State.Closed,
},
documentId: 'slot-123',
};
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
query: mockQuery,
});
const result = slotsService.updateSlot(variablesWithoutTime);
await expect(result).resolves.toBe(mockMutationResult.data);
});
it('should throw error when user does not have permission', async () => {
const unrelatedCustomer = {
...mockCustomer,
documentId: 'different-customer-123',
};
const mockQuery = vi
.fn()
.mockResolvedValueOnce({
data: { customers: [unrelatedCustomer] },
})
.mockResolvedValueOnce({
data: { slot: mockSlot }, // slot принадлежит другому пользователю
});
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve({
data: { customers: [unrelatedCustomer] },
});
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: mockSlot }, // slot принадлежит другому пользователю
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
@ -119,12 +168,19 @@ describe('SlotsService', () => {
});
it('should throw error when slot does not exist', async () => {
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValueOnce({
data: { slot: null }, // slot не найден
});
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: null }, // slot не найден
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
@ -133,12 +189,18 @@ describe('SlotsService', () => {
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow();
await expect(result).rejects.toThrow(ERRORS.SLOT_NOT_FOUND);
});
it('should throw error when customer is not found', async () => {
const mockQuery = vi.fn().mockResolvedValue({
data: { customers: [] }, // пользователь не найден
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve({
data: { customers: [] }, // пользователь не найден
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
@ -150,67 +212,469 @@ describe('SlotsService', () => {
await expect(result).rejects.toThrow('Customer not found');
});
});
describe('checkPermission', () => {
const mockVariables = {
documentId: 'slot-123',
};
it('should not throw error when user has permission', async () => {
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValueOnce(mockGetSlotResult);
mockGetClientWithToken.mockResolvedValue({
query: mockQuery,
});
const result = slotsService.checkPermission(mockVariables);
await expect(result).resolves.toBeUndefined();
});
it('should throw error when user does not have permission', async () => {
const unrelatedCustomer = {
...mockCustomer,
documentId: 'different-customer-123',
it('should throw error when datetime_start is missing', async () => {
const variablesWithMissingStart = {
data: {
datetime_end: '2024-01-01T11:00:00Z',
},
documentId: 'slot-123',
};
const mockQuery = vi
.fn()
.mockResolvedValueOnce({
data: { customers: [unrelatedCustomer] },
})
.mockResolvedValueOnce({
data: { slot: mockSlot }, // slot принадлежит другому пользователю
});
const slotWithoutStart = {
...mockSlot,
datetime_start: null,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithoutStart },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.checkPermission(mockVariables);
const result = slotsService.updateSlot(variablesWithMissingStart);
await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION);
await expect(result).rejects.toThrow(ERRORS.INVALID_TIME);
});
it('should throw error when slot does not exist', async () => {
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValueOnce({
data: { slot: null }, // slot не найден
});
it('should throw error when datetime_end is missing', async () => {
const variablesWithMissingEnd = {
data: {
datetime_start: '2024-01-01T10:00:00Z',
},
documentId: 'slot-123',
};
const slotWithoutEnd = {
...mockSlot,
datetime_end: null,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithoutEnd },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.checkPermission(mockVariables);
const result = slotsService.updateSlot(variablesWithMissingEnd);
await expect(result).rejects.toThrow();
await expect(result).rejects.toThrow(ERRORS.INVALID_TIME);
});
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',
},
documentId: 'slot-123',
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(variablesWithInvalidTime);
await expect(result).rejects.toThrow(ERRORS.INVALID_TIME);
});
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',
},
documentId: 'slot-123',
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(variablesWithEqualTime);
await expect(result).rejects.toThrow(ERRORS.INVALID_TIME);
});
it('should throw error when slot has scheduled orders', async () => {
const slotWithScheduledOrders = {
...mockSlot,
orders: [
{
datetime_end: '2024-01-01T10:30:00Z',
datetime_start: '2024-01-01T10:00:00Z',
documentId: 'order-123',
state: GQL.Enum_Order_State.Scheduled,
},
],
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithScheduledOrders },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS);
});
it('should throw error when slot has approved orders', async () => {
const slotWithApprovedOrders = {
...mockSlot,
orders: [
{
datetime_end: '2024-01-01T10:30:00Z',
datetime_start: '2024-01-01T10:00:00Z',
documentId: 'order-123',
state: GQL.Enum_Order_State.Approved,
},
],
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithApprovedOrders },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS);
});
it('should throw error when slot has completed orders', async () => {
const slotWithCompletedOrders = {
...mockSlot,
orders: [
{
datetime_end: '2024-01-01T10:30:00Z',
datetime_start: '2024-01-01T10:00:00Z',
documentId: 'order-123',
state: GQL.Enum_Order_State.Completed,
},
],
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithCompletedOrders },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS);
});
it('should throw error when slot has cancelled orders', async () => {
const slotWithCancelledOrders = {
...mockSlot,
orders: [
{
datetime_end: '2024-01-01T10:30:00Z',
datetime_start: '2024-01-01T10:00:00Z',
documentId: 'order-123',
state: GQL.Enum_Order_State.Cancelled,
},
],
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithCancelledOrders },
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS);
});
it('should allow update when slot has non-forbidden order states', async () => {
const slotWithNonForbiddenOrders = {
...mockSlot,
orders: [
{
datetime_end: '2024-01-01T10:30:00Z',
datetime_start: '2024-01-01T10:00:00Z',
documentId: 'order-123',
state: GQL.Enum_Order_State.Draft, // не запрещенное состояние
},
],
};
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithNonForbiddenOrders },
});
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({ data: { slots: [] } }); // нет пересекающихся слотов
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).resolves.toBe(mockMutationResult.data);
});
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',
documentId: 'slot-456',
master: mockCustomer,
orders: [],
state: GQL.Enum_Slot_State.Open,
};
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({
data: { slots: [overlappingSlot] },
}); // есть пересекающиеся слоты
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME);
});
it('should allow update 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(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({
data: { slots: [] },
}); // нет пересекающихся слотов
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
query: mockQuery,
});
const result = slotsService.updateSlot(mockVariables);
await expect(result).resolves.toBe(mockMutationResult.data);
});
it('should use existing slot times when only datetime_start is provided', async () => {
const variablesWithOnlyStart = {
data: {
datetime_start: '2024-01-01T09:00:00Z',
},
documentId: 'slot-123',
};
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({ data: { slots: [] } });
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
query: mockQuery,
});
const result = slotsService.updateSlot(variablesWithOnlyStart);
await expect(result).resolves.toBe(mockMutationResult.data);
});
it('should use existing slot times when only datetime_end is provided', async () => {
const variablesWithOnlyEnd = {
data: {
datetime_end: '2024-01-01T12:00:00Z',
},
documentId: 'slot-123',
};
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
if (query === GQL.GetSlotsDocument) {
return Promise.resolve({ data: { slots: [] } });
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
query: mockQuery,
});
const result = slotsService.updateSlot(variablesWithOnlyEnd);
await expect(result).resolves.toBe(mockMutationResult.data);
});
});
@ -230,10 +694,17 @@ describe('SlotsService', () => {
it('should successfully delete slot when no orders', async () => {
const mockMutate = vi.fn().mockResolvedValue(mockMutationResult);
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValue(mockGetSlotResult);
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve(mockGetSlotResult);
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: mockMutate,
@ -258,12 +729,19 @@ describe('SlotsService', () => {
],
};
const mockQuery = vi
.fn()
.mockResolvedValueOnce(mockGetCustomerResult)
.mockResolvedValue({
data: { slot: slotWithOrders }, // slot с заказами
});
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve(mockGetCustomerResult);
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: slotWithOrders }, // slot с заказами
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),
@ -281,14 +759,21 @@ describe('SlotsService', () => {
documentId: 'different-customer-123',
};
const mockQuery = vi
.fn()
.mockResolvedValueOnce({
data: { customers: [unrelatedCustomer] },
})
.mockResolvedValueOnce({
data: { slot: mockSlot }, // slot принадлежит другому пользователю
});
const mockQuery = vi.fn().mockImplementation(({ query }) => {
if (query === GQL.GetCustomerDocument) {
return Promise.resolve({
data: { customers: [unrelatedCustomer] },
});
}
if (query === GQL.GetSlotDocument) {
return Promise.resolve({
data: { slot: mockSlot }, // slot принадлежит другому пользователю
});
}
return Promise.resolve({ data: {} });
});
mockGetClientWithToken.mockResolvedValue({
mutate: vi.fn(),

View File

@ -1,3 +1,5 @@
/* 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';
@ -7,13 +9,23 @@ import { getMinutes } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
export const ERRORS = {
HAS_ORDERS: 'Slot has orders',
MISSING_DATE: 'Missing date',
MISSING_SERVICE: 'Missing service',
MISSING_SERVICE_ID: 'Missing service id',
NO_PERMISSION: 'No permission',
HAS_ORDERS: 'Слот имеет активные заказы',
INVALID_TIME: 'Некорректное время',
MISSING_DATE: 'Не указана дата',
MISSING_SERVICE_ID: 'Не указана услуга',
NO_PERMISSION: 'Нет доступа',
OVERLAPPING_TIME: 'Время пересекается с другими слотами',
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>) {
const { customer } = await this._getUser();
@ -86,7 +98,7 @@ export class SlotsService extends BaseService {
documentId: context.service.documentId.eq,
});
if (!service) throw new Error(ERRORS.MISSING_SERVICE);
if (!service) throw new Error(ERRORS.SERVICE_NOT_FOUND);
const serviceDuration = getMinutes(service.duration);
@ -149,6 +161,7 @@ export class SlotsService extends BaseService {
async updateSlot(variables: VariablesOf<typeof GQL.UpdateSlotDocument>) {
await this.checkPermission(variables);
await this.checkIsTimeChanging(variables);
const { mutate } = await getClientWithToken();
@ -163,6 +176,55 @@ export class SlotsService extends BaseService {
return mutationResult.data;
}
private async checkIsTimeChanging(variables: VariablesOf<typeof GQL.UpdateSlotDocument>) {
const { slot } = await this.getSlot({ documentId: variables.documentId });
if (!slot) throw new Error(ERRORS.SLOT_NOT_FOUND);
const isTimeChanging = variables?.data?.datetime_start || variables?.data?.datetime_end;
if (!isTimeChanging) return;
let datetime_start = variables.data.datetime_start;
let datetime_end = variables.data.datetime_end;
if (!datetime_start) datetime_start = slot?.datetime_start;
if (!datetime_end) datetime_end = slot?.datetime_end;
// Проверка: оба времени должны быть определены
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 orders = slot?.orders;
if (
orders?.length &&
orders?.some((order) => order?.state && FORBIDDEN_ORDER_STATES.includes(order.state))
) {
throw new Error(ERRORS.HAS_ORDERS);
}
const { documentId } = slot;
const overlappingEntities = await this.getSlots({
filters: {
datetime_end: { gt: datetime_start },
datetime_start: { lt: datetime_end },
documentId: { not: { eq: documentId } },
},
});
if (overlappingEntities?.slots?.length) {
throw new Error(ERRORS.OVERLAPPING_TIME);
}
}
private async checkPermission(
variables: Pick<VariablesOf<typeof GQL.GetSlotDocument>, 'documentId'>,
) {
@ -170,6 +232,8 @@ export class SlotsService extends BaseService {
const { slot } = await this.getSlot({ documentId: variables.documentId });
if (!slot) throw new Error(ERRORS.SLOT_NOT_FOUND);
if (slot?.master?.documentId !== customer?.documentId) throw new Error(ERRORS.NO_PERMISSION);
}
}