* feat(profile): add subscription information to profile page - Integrated `SubscriptionInfoBar` component into the profile page for displaying subscription details. - Updated GraphQL types to include subscription-related fields and filters. - Enhanced the profile data management by adding subscription handling capabilities. - Added a new utility function `getRemainingDays` to calculate remaining days until a specified date. * refactor(tests): remove BOT_TOKEN from environment mocks in order and slots tests - Eliminated the hardcoded BOT_TOKEN from the environment mock in both orders.test.js and slots.test.js to streamline test configurations and improve security practices. * feat(env): add BOT_URL to environment variables and update related configurations - Introduced BOT_URL to the environment schema for enhanced configuration management. - Updated turbo.json to include BOT_URL in the environment variables list. - Modified subscription-bar.tsx to improve user messaging for subscription availability. * feat(pro-page): use next/link * feat(pro-page): enhance subscription messaging and add benefits section - Updated subscription status messaging for clarity and conciseness. - Improved button styling based on trial availability. - Added a new benefits section for non-active subscribers, highlighting key features of the Pro subscription. * fix(pro-page): adjust hero section layout for improved visual consistency - Reduced margin in the hero section to enhance alignment and overall aesthetics of the Pro page. * feat(subscriptions): add trial subscription functionality - Implemented `createTrialSubscription` action in the API for initiating trial subscriptions. - Enhanced the Pro page to include a `TryFreeButton` for users to activate their trial. - Updated GraphQL operations and types to support trial subscription features. - Improved subscription messaging and user experience across relevant components. * refactor(pro-page): streamline ProPage layout and improve bottom navigation visibility - Consolidated the main container for the ProPage to enhance layout consistency. - Updated the BottomNav component to conditionally hide on the Pro page, improving navigation clarity for users. * feat(subscriptions): add trial period validation for subscriptions - Implemented a check to verify if a user has already utilized their trial period before allowing access to subscription services. - Enhanced error handling to provide a clear message when a trial period has been previously used, improving user experience and subscription management. * style(pro-page, subscription-bar): enhance dark mode support and improve styling consistency - Updated gradient backgrounds in ProPage and SubscriptionInfoBar to support dark mode variations. - Refactored class names for better conditional styling based on subscription activity. - Improved text color handling for better readability in both active and inactive states. * feat(orders, subscriptions): implement banned user checks and improve remaining orders calculation - Added `checkIsBanned` method calls in the `createOrder`, `getOrder`, `getOrders`, and `updateOrder` methods of the `OrdersService` to prevent actions from banned users. - Updated the calculation of `remainingOrdersCount` in the `SubscriptionsService` to ensure it does not go below zero, enhancing subscription management accuracy. * feat(subscriptions): enhance error handling with centralized error messages - Introduced a centralized `ERRORS` object in the `subscriptions.ts` file to standardize error messages related to trial subscriptions. - Updated error handling in the `createSubscription` method to utilize the new error messages, improving maintainability and clarity for subscription-related errors. * feat(orders): implement order limit checks for clients and masters - Added order limit validation in the `OrdersService` to check if a master has reached their monthly order limit. - Introduced new error messages for exceeding order limits, enhancing user feedback for both clients and masters. - Integrated `SubscriptionsService` to manage subscription status and remaining order counts effectively. * order-card: fix order_number badge overlays navigation bar * fix(docker-compose): update healthcheck endpoint to include API path * feat(profile): add MasterServicesList component to display services for profile masters - Introduced the MasterServicesList component to show services associated with a master profile. - Updated ProfilePage to conditionally render MasterServicesList based on user role. - Refactored services fetching logic into a new useMasterServices hook for better reusability. * feat(profile): conditionally render SubscriptionInfoBar based on user role - Updated ProfilePage to check if the user is a master and conditionally render the SubscriptionInfoBar component. - Refactored customer fetching logic to include a utility function for determining user role. * fix tests * fix(typo): rename updateSlot to updateOrder for clarity * refactor(contact): remove customer master role checks and simplify contact addition - Updated the `addContact` function to allow all users to add contacts, removing the previous restriction that only masters could do so. - Deleted the `become-master` feature and related utility functions, streamlining the codebase. - Adjusted command settings to reflect the removal of the master role functionality. - Refactored components and hooks to eliminate dependencies on the master role, enhancing user experience and simplifying logic. * refactor(contacts): rename masters to invited and update related functionality - Changed the terminology from "masters" to "invited" and "invitedBy" across the codebase for clarity and consistency. - Updated the `addContact` function to reflect the new naming convention. - Refactored API actions and server methods to support the new invited structure. - Adjusted components and hooks to utilize the updated invited data, enhancing user experience and simplifying logic. * feat(profile): enhance user role checks in subscription and links components - Added conditional rendering in SubscriptionInfoBar and LinksCard to hide components for users with the Client role. - Updated ProfileDataCard to use Enum_Customer_Role for role management. - Improved error handling in OrdersService to differentiate between master and client order limit errors. * refactor(contacts): update grid components and improve customer role handling - Renamed InvitedByGrid to MastersGrid and InvitedGrid to ClientsGrid for clarity. - Enhanced customer role checks by using documentId for identifying the current user. - Updated the contacts passed to grid components to reflect the new naming and role structure. - Adjusted titles in grid components for better user experience. * feat(order): enhance order initialization logic with additional client selection step - Added a new step for client selection in the order initialization process when only a masterId is present. - Disabled cognitive complexity checks for improved code maintainability. * feat(contacts): add showServices prop to ContactRow for conditional rendering - Updated ContactsList to pass showServices prop to ContactRow. - Modified ContactRow to conditionally render services based on the showServices prop, enhancing the display of contact information. * feat(contacts): add DataNotFound component for empty states in contacts and services grids - Integrated DataNotFound component to display a message when no contacts or services are found in the respective grids. - Enhanced loading state handling in ServicesSelect and ScheduleCalendar components to improve user experience during data fetching. * feat(contacts): enhance contact display and improve user experience - Updated ContactsList to include a description prop in ContactRow for better service representation. - Renamed header in OrderContacts from "Контакты" to "Участники" for clarity. - Replaced Avatar components with UserAvatar in various components for consistent user representation. - Filtered active contacts in MastersGrid and ClientsGrid to improve data handling. - Adjusted customer query logic to ensure proper handling of telegramId. * feat(customers): add getCustomers API and enhance customer queries - Introduced getCustomers action and corresponding server method to fetch customer data with pagination and sorting. - Updated hooks to support infinite querying of customers, improving data handling in components. - Refactored ContactsList and related components to utilize the new customer fetching logic, enhancing user experience. - Adjusted filter labels in dropdowns for better clarity and user understanding. * refactor(contacts): consolidate customer queries and enhance contact handling - Replaced use of useCustomersInfiniteQuery with a new useContactsInfiniteQuery hook for improved data fetching. - Simplified ContactsList and MastersGrid components by removing unnecessary customer documentId filters. - Deleted outdated contact-related hooks and queries to streamline the codebase. - Enhanced loading state management across components for better user experience. * fix(avatar): update UserAvatar sizes for consistency across components - Changed UserAvatar size from 'xl' to 'lg' in PersonCard for better alignment with design. - Adjusted UserAvatar size from 'sm' to 'xs' in OrderCard to ensure uniformity in avatar presentation. - Updated sizeClasses in UserAvatar component to reflect the new 'xs' size, enhancing responsiveness. * fix(auth): ensure telegramId is a string for consistent handling - Updated the signIn calls in both Auth and useAuth functions to convert telegramId to a string. - Modified the JWT callback to store telegramId as a string in the token. - Enhanced session handling to correctly assign telegramId from the token to the session user. - Added type definitions for telegramId in next-auth to ensure proper type handling. * fix(auth): handle unregistered users in authentication flow - Updated the authentication logic in both Auth and useAuth functions to redirect unregistered users to the '/unregistered' page. - Enhanced error handling in the authOptions to check for user registration status using the Telegram ID. - Improved the matcher configuration in middleware to exclude the '/unregistered' route from authentication checks. * feat(subscriptions): add SubscriptionRewardFields and update related types - Introduced SubscriptionRewardFields fragment to encapsulate reward-related data for subscriptions. - Updated CustomerFiltersInput and SubscriptionHistoryFiltersInput to include subscription_rewards for enhanced filtering capabilities. - Added SubscriptionRewardFiltersInput and SubscriptionRewardInput types to support reward management in subscriptions. - Modified existing fragments and queries to reflect the new structure and ensure consistency across the codebase. * test payment * feat(subscriptions): refactor subscription handling and update related queries - Renamed `hasUserTrialSubscription` to `usedTrialSubscription` for clarity in the SubscriptionsService. - Updated subscription-related queries and fragments to use `active` instead of `isActive` for consistency. - Enhanced the ProPage component to utilize the new subscription checks and improve trial usage logic. - Removed unused subscription history query to streamline the codebase. - Adjusted the SubscriptionInfoBar to reflect the new subscription state handling. * feat(subscriptions): update subscription messages and enhance bot functionality - Renamed `msg-subscribe-active-until` to `msg-subscription-active-until` for consistency in localization. - Added `msg-subscription-active-days` to inform users about remaining subscription days. - Refactored subscription handling in the bot to utilize updated subscription checks and improve user messaging. - Enhanced conversation flow by integrating chat action for typing indication during subscription interactions. * feat(subscriptions): enhance subscription handling and localization updates - Added new message `msg-subscribe-disabled` to inform users when their subscription is disabled. - Updated `msg-subscription-active-days` to ensure proper localization formatting. - Refactored subscription command in the bot to check for subscription status and respond accordingly. - Enhanced ProfilePage to conditionally render the SubscriptionInfoBar based on subscription status. - Updated GraphQL types and queries to include `proEnabled` for better subscription management. * feat(bot): enhance conversation handling by removing redundant typing indication - Added a chat action for 'typing' indication at the start of the bot's conversation flow. - Removed the redundant 'typing' action from individual conversation handlers to streamline the code. * feat(localization): update Russian localization with support contact and message adjustments - Added a new support contact message for user inquiries. - Refactored existing messages to utilize the new support contact variable for consistency. - Cleaned up redundant messages and ensured proper localization formatting across various sections. * feat(localization): add Pro subscription information and update command list - Introduced new localization entry for Pro subscription information. - Updated command list to include 'pro' command for better user guidance. - Enhanced existing subscription messages for clarity and consistency. * feat(localization): update Pro access terminology and enhance subscription messages - Replaced instances of "подписка" with "доступ" to clarify Pro access terminology. - Updated subscription-related messages for improved user understanding and consistency. - Enhanced command list and bot responses to reflect changes in Pro access messaging. * feat(subscriptions): enhance subscription flow and localization updates - Updated default locale to Russian for improved user experience. - Refactored subscription messages to include expiration dates and active subscription status. - Enhanced keyboard display for subscription options with clear expiration information. - Improved handling of subscription-related queries and responses for better clarity. * update support contact * update bot description * .github\workflows\deploy.yml: add BOT_PROVIDER_TOKEN
1569 lines
46 KiB
JavaScript
1569 lines
46 KiB
JavaScript
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 { SubscriptionsService } from './subscriptions';
|
||
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('./subscriptions');
|
||
vi.mock('../config/env', () => {
|
||
return {
|
||
env: {
|
||
LOGIN_GRAPHQL: 'test',
|
||
PASSWORD_GRAPHQL: 'test',
|
||
URL_GRAPHQL: 'test',
|
||
},
|
||
};
|
||
});
|
||
|
||
const mockGetClientWithToken = vi.mocked(getClientWithToken);
|
||
const mockServicesService = vi.mocked(ServicesService);
|
||
const mockSubscriptionsService = vi.mocked(SubscriptionsService);
|
||
|
||
describe('SlotsService', () => {
|
||
/**
|
||
* @type {SlotsService}
|
||
*/
|
||
let slotsService;
|
||
const mockUser = { telegramId: 123_456_789 };
|
||
|
||
const mockCustomer = {
|
||
documentId: 'customer-123',
|
||
firstName: 'John',
|
||
lastName: 'Doe',
|
||
telegramId: 123_456_789,
|
||
};
|
||
|
||
const now = dayjs().minute(0).second(0).millisecond(0);
|
||
vi.setSystemTime(now.toDate());
|
||
|
||
const mockSlot = {
|
||
datetime_end: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
documentId: 'slot-123',
|
||
master: mockCustomer,
|
||
orders: [],
|
||
state: GQL.Enum_Slot_State.Open,
|
||
};
|
||
|
||
const mockGetCustomerResult = {
|
||
data: {
|
||
customers: [mockCustomer],
|
||
},
|
||
};
|
||
|
||
const mockGetSlotResult = {
|
||
data: {
|
||
slot: mockSlot,
|
||
},
|
||
};
|
||
|
||
beforeEach(() => {
|
||
slotsService = new SlotsService(mockUser);
|
||
vi.clearAllMocks();
|
||
|
||
// Глобальный мок для checkIsBanned
|
||
vi.spyOn(slotsService, 'checkIsBanned').mockResolvedValue({
|
||
customer: mockCustomer,
|
||
});
|
||
|
||
// Глобальный мок для SubscriptionsService
|
||
mockSubscriptionsService.mockImplementation(() => ({
|
||
getSubscription: vi.fn().mockResolvedValue({
|
||
maxOrdersPerMonth: 10,
|
||
remainingOrdersCount: 5,
|
||
subscription: {
|
||
autoRenew: false,
|
||
documentId: 'subscription-123',
|
||
expiresAt: now.add(30, 'day').toISOString(),
|
||
isActive: true,
|
||
},
|
||
}),
|
||
getSubscriptionSettings: vi.fn().mockResolvedValue({
|
||
subscriptionSetting: {
|
||
documentId: 'subscription-setting-123',
|
||
maxOrdersPerMonth: 10,
|
||
referralBonusDays: 3,
|
||
referralRewardDays: 7,
|
||
},
|
||
}),
|
||
}));
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('getAvailableTimeSlots', () => {
|
||
const mockVariables = {
|
||
filters: {
|
||
datetime_start: now.toISOString(),
|
||
},
|
||
};
|
||
|
||
const mockContext = {
|
||
services: ['service-123', 'service-456'],
|
||
};
|
||
|
||
const mockService1 = {
|
||
active: true,
|
||
documentId: 'service-123',
|
||
duration: '01:00:00', // 1 час
|
||
master: mockCustomer,
|
||
name: 'Test Service 1',
|
||
};
|
||
|
||
const mockService2 = {
|
||
active: true,
|
||
documentId: 'service-456',
|
||
duration: '00:30:00', // 30 минут
|
||
master: mockCustomer,
|
||
name: 'Test Service 2',
|
||
};
|
||
|
||
const mockGetServiceResult1 = {
|
||
service: mockService1,
|
||
};
|
||
|
||
const mockGetServiceResult2 = {
|
||
service: mockService2,
|
||
};
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1) // Первый вызов для service-123
|
||
.mockResolvedValueOnce(mockGetServiceResult2); // Второй вызов для service-456
|
||
|
||
mockServicesService.mockImplementation(() => ({
|
||
getService: mockGetService,
|
||
}));
|
||
|
||
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
||
|
||
expect(result).toEqual({
|
||
slots: [mockSlotWithOrders, mockSlotWithoutOrders],
|
||
times: [
|
||
{
|
||
slotId: 'slot-124',
|
||
time: now.add(3, 'hour').toISOString(), // 18:00
|
||
},
|
||
{
|
||
slotId: 'slot-124',
|
||
time: now.add(3, 'hour').add(15, 'minute').toISOString(), // 18:15
|
||
},
|
||
{
|
||
slotId: 'slot-124',
|
||
time: now.add(3, 'hour').add(30, 'minute').toISOString(), // 18:30
|
||
},
|
||
],
|
||
});
|
||
|
||
// Проверяем, что getService был вызван для каждого сервиса
|
||
expect(mockGetService).toHaveBeenCalledTimes(2);
|
||
expect(mockGetService).toHaveBeenNthCalledWith(1, { documentId: 'service-123' });
|
||
expect(mockGetService).toHaveBeenNthCalledWith(2, { documentId: 'service-456' });
|
||
});
|
||
|
||
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 services array is missing', async () => {
|
||
const contextWithoutServices = {
|
||
services: [],
|
||
};
|
||
|
||
const result = slotsService.getAvailableTimeSlots(mockVariables, contextWithoutServices);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.MISSING_SERVICES_IDS);
|
||
});
|
||
|
||
it('should throw error when services array is null', async () => {
|
||
const contextWithNullServices = {
|
||
services: null,
|
||
};
|
||
|
||
const result = slotsService.getAvailableTimeSlots(mockVariables, contextWithNullServices);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.MISSING_SERVICES_IDS);
|
||
});
|
||
|
||
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({
|
||
service: null,
|
||
});
|
||
mockServicesService.mockImplementation(() => ({
|
||
getService: mockGetService,
|
||
}));
|
||
|
||
const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.NOT_FOUND_SERVICE);
|
||
});
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1)
|
||
.mockResolvedValueOnce(mockGetServiceResult2);
|
||
|
||
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 () => {
|
||
// Устанавливаем текущее время на 5 минут раньше начала слота
|
||
const currentTime = now.subtract(5, 'minute');
|
||
vi.setSystemTime(currentTime.toDate());
|
||
|
||
const slotWithCancelledOrder = {
|
||
datetime_end: now.add(2, 'hour').toISOString(), // 12:00
|
||
datetime_start: now.toISOString(), // 10:00
|
||
documentId: 'slot-cancelled',
|
||
master: mockCustomer,
|
||
orders: [
|
||
{
|
||
datetime_end: now.add(1, 'hour').toISOString(), // 11:00
|
||
datetime_start: now.toISOString(), // 10:00
|
||
documentId: 'order-123',
|
||
state: GQL.Enum_Order_State.Cancelled,
|
||
},
|
||
],
|
||
state: GQL.Enum_Slot_State.Open,
|
||
};
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1)
|
||
.mockResolvedValueOnce(mockGetServiceResult2);
|
||
|
||
mockServicesService.mockImplementation(() => ({
|
||
getService: mockGetService,
|
||
}));
|
||
|
||
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
||
|
||
// Слот: 10:00–12:00, общая длительность сервисов = 1.5 часа (90 мин)
|
||
// datetimeEnd для генерации = 12:00 - 90 мин = 10:30
|
||
// Шаг = 15 мин
|
||
// Доступные времена: 10:00, 10:15, 10:30 (но 10:00 не пройдет проверку isAfter(now, 'minute'))
|
||
// Текущее время = 9:55, поэтому 10:00 > 9:55 на 5 минут > 1 минуту
|
||
const expectedTimes = [
|
||
now.toISOString(), // 10:00
|
||
now.add(15, 'minute').toISOString(), // 10:15
|
||
now.add(30, 'minute').toISOString(), // 10:30
|
||
];
|
||
|
||
const actualTimes = result.times
|
||
.filter((time) => time.slotId === slotWithCancelledOrder.documentId)
|
||
.map((time) => time.time);
|
||
|
||
expect(actualTimes).toEqual(expectedTimes);
|
||
|
||
// Восстанавливаем исходное время
|
||
vi.setSystemTime(now.toDate());
|
||
});
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1)
|
||
.mockResolvedValueOnce(mockGetServiceResult2);
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1)
|
||
.mockResolvedValueOnce(mockGetServiceResult2);
|
||
|
||
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()
|
||
.mockResolvedValueOnce(mockGetServiceResult1)
|
||
.mockResolvedValueOnce(mockGetServiceResult2);
|
||
|
||
mockServicesService.mockImplementation(() => ({
|
||
getService: mockGetService,
|
||
}));
|
||
|
||
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
||
|
||
// Проверяем, что слот без datetime_start пропущен
|
||
expect(result.times).toHaveLength(0);
|
||
});
|
||
|
||
it('should calculate total service duration correctly', async () => {
|
||
const serviceWithDuration1 = {
|
||
...mockService1,
|
||
duration: '00:30:00', // 30 минут
|
||
};
|
||
|
||
const serviceWithDuration2 = {
|
||
...mockService2,
|
||
duration: '00:45:00', // 45 минут
|
||
};
|
||
|
||
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()
|
||
.mockResolvedValueOnce({ service: serviceWithDuration1 })
|
||
.mockResolvedValueOnce({ service: serviceWithDuration2 });
|
||
|
||
mockServicesService.mockImplementation(() => ({
|
||
getService: mockGetService,
|
||
}));
|
||
|
||
const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext);
|
||
|
||
// Проверяем, что времена генерируются с учетом общей длительности услуг (30 + 45 = 75 минут)
|
||
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 totalServiceDuration = dayjs.duration('01:15:00'); // 75 минут
|
||
const maxTime = slotEnd.subtract(totalServiceDuration);
|
||
|
||
expect(lastTime.valueOf()).toBeLessThanOrEqual(maxTime.valueOf());
|
||
});
|
||
});
|
||
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
state: GQL.Enum_Slot_State.Open,
|
||
},
|
||
documentId: 'slot-123',
|
||
};
|
||
|
||
const mockMutationResult = {
|
||
data: {
|
||
updateSlot: mockSlot,
|
||
},
|
||
errors: undefined,
|
||
};
|
||
|
||
it('should successfully update slot when user has permission', 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 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().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(),
|
||
query: mockQuery,
|
||
});
|
||
|
||
const result = slotsService.updateSlot(mockVariables);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION);
|
||
});
|
||
|
||
it('should throw error when slot does not exist', async () => {
|
||
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(),
|
||
query: mockQuery,
|
||
});
|
||
|
||
const result = slotsService.updateSlot(mockVariables);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.SLOT_NOT_FOUND);
|
||
});
|
||
|
||
it('should throw error when customer 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.updateSlot(mockVariables);
|
||
|
||
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: now.add(6, 'hour').toISOString(),
|
||
},
|
||
documentId: 'slot-123',
|
||
};
|
||
|
||
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.updateSlot(variablesWithMissingStart);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START);
|
||
});
|
||
|
||
it('should throw error when datetime_end is missing', async () => {
|
||
const variablesWithMissingEnd = {
|
||
data: {
|
||
datetime_start: now.toISOString(),
|
||
},
|
||
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.updateSlot(variablesWithMissingEnd);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_END);
|
||
});
|
||
|
||
it('should throw error when datetime_end is before datetime_start', async () => {
|
||
const variablesWithInvalidTime = {
|
||
data: {
|
||
datetime_end: now.toISOString(),
|
||
datetime_start: now.add(6, 'hour').toISOString(),
|
||
},
|
||
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: now.toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
},
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('deleteSlot', () => {
|
||
const mockVariables = {
|
||
documentId: 'slot-123',
|
||
};
|
||
|
||
const mockMutationResult = {
|
||
data: {
|
||
deleteSlot: {
|
||
documentId: 'slot-123',
|
||
},
|
||
},
|
||
errors: undefined,
|
||
};
|
||
|
||
it('should successfully delete slot when no orders', 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);
|
||
}
|
||
|
||
return Promise.resolve({ data: {} });
|
||
});
|
||
|
||
mockGetClientWithToken.mockResolvedValue({
|
||
mutate: mockMutate,
|
||
query: mockQuery,
|
||
});
|
||
|
||
const result = slotsService.deleteSlot(mockVariables);
|
||
|
||
await expect(result).resolves.toBe(mockMutationResult.data);
|
||
});
|
||
|
||
it('should throw error when slot has orders', async () => {
|
||
const slotWithOrders = {
|
||
...mockSlot,
|
||
orders: [
|
||
{
|
||
datetime_end: now.add(6, 'hour').toISOString(),
|
||
datetime_start: now.toISOString(),
|
||
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: slotWithOrders }, // slot с заказами
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ data: {} });
|
||
});
|
||
|
||
mockGetClientWithToken.mockResolvedValue({
|
||
mutate: vi.fn(),
|
||
query: mockQuery,
|
||
});
|
||
|
||
const result = slotsService.deleteSlot(mockVariables);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS);
|
||
});
|
||
|
||
it('should throw error when user does not have permission', async () => {
|
||
const unrelatedCustomer = {
|
||
...mockCustomer,
|
||
documentId: 'different-customer-123',
|
||
};
|
||
|
||
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(),
|
||
query: mockQuery,
|
||
});
|
||
|
||
const result = slotsService.deleteSlot(mockVariables);
|
||
|
||
await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION);
|
||
});
|
||
});
|
||
});
|