Files
portal/app/tests/Frontend/admin-sms-api.spec.ts
T
Дмитрий 86d974330b feat(смс-клиент): правило «с 10 до 20 по местному» живёт в одном месте, границы правит владелец
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7.
Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то,
на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить.

1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке,
   что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались.
   Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская
   область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю»,
   а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном.

2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда
   откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз
   на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе.

3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20.
   Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна
   оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют
   привилегии таблицы. Запись схемы v9.12.

4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени
   получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь,
   поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно,
   а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91.

Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных
и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок.
Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ
с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило
перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20.

Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань
приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо
и проверяют, за какое поле ругаются.
2026-07-28 15:20:13 +03:00

154 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../resources/js/api/client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
ensureCsrfCookie: vi.fn(),
}));
import {
fetchSmsTariffs,
updateSmsTariffs,
updateSmsSettings,
fetchSmsSenders,
approveSmsSender,
rejectSmsSender,
disableSmsSender,
} from '../../resources/js/api/admin';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
describe('api/admin — СМС: тарифы и имена (Task 16b)', () => {
beforeEach(() => vi.clearAllMocks());
it('fetchSmsTariffs() GETs /api/admin/sms/tariffs', async () => {
const payload = {
tariffs: [{ id: 1, min_qty: 1, price_rub: '3.50' }],
settings: { name_fee_rub_per_operator: '500.00', name_debt_grace_days: 5 },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: payload });
const r = await fetchSmsTariffs();
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/sms/tariffs');
expect(r).toEqual(payload);
});
it('updateSmsTariffs(rows) ensureCsrfCookie ПЕРЕД PUT /api/admin/sms/tariffs с {rows}', async () => {
const rows = [
{ min_qty: 1, price_rub: '3.50' },
{ min_qty: 1000, price_rub: '3.00' },
];
vi.mocked(apiClient.put).mockResolvedValue({
data: { tariffs: [{ id: 1, min_qty: 1, price_rub: '3.50' }] },
});
const r = await updateSmsTariffs(rows);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.put).toHaveBeenCalledWith('/api/admin/sms/tariffs', { rows });
const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0];
const putOrder = vi.mocked(apiClient.put).mock.invocationCallOrder[0];
expect(csrfOrder).toBeLessThan(putOrder);
expect(r.tariffs).toHaveLength(1);
});
it('updateSmsSettings(payload) PUTs /api/admin/sms/settings с телом, включая границы окна', async () => {
vi.mocked(apiClient.put).mockResolvedValue({
data: {
name_fee_rub_per_operator: '600.00',
name_debt_grace_days: 7,
quiet_from_hour: 11,
quiet_to_hour: 19,
},
});
const r = await updateSmsSettings({
name_fee_rub_per_operator: '600.00',
name_debt_grace_days: 7,
quiet_from_hour: 11,
quiet_to_hour: 19,
});
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.put).toHaveBeenCalledWith('/api/admin/sms/settings', {
name_fee_rub_per_operator: '600.00',
name_debt_grace_days: 7,
quiet_from_hour: 11,
quiet_to_hour: 19,
});
expect(r.name_debt_grace_days).toBe(7);
expect(r.quiet_from_hour).toBe(11);
});
it('fetchSmsSenders(status) GETs /api/admin/sms/senders с params.status', async () => {
const senders = [
{
id: 1,
tenant_id: 5,
name: 'ОКНА МСК',
name_type: 'company',
status: 'pending',
monthly_fee_rub: '500.00',
note: null,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: { senders } });
const r = await fetchSmsSenders('pending');
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/sms/senders', { params: { status: 'pending' } });
expect(r).toEqual(senders);
});
it('fetchSmsSenders() без статуса — params пустой', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { senders: [] } });
await fetchSmsSenders();
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/sms/senders', { params: {} });
});
it('approveSmsSender(id) POSTs /api/admin/sms/senders/{id}/approve', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, status: 'active' } });
const r = await approveSmsSender(1);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/sms/senders/1/approve');
expect(r.status).toBe('active');
});
it('rejectSmsSender(id, note) POSTs /api/admin/sms/senders/{id}/reject с note', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, status: 'rejected' } });
const r = await rejectSmsSender(1, 'Похоже на чужой бренд');
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/sms/senders/1/reject', {
note: 'Похоже на чужой бренд',
});
expect(r.status).toBe('rejected');
});
it('rejectSmsSender(id) без note шлёт пустое тело', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, status: 'rejected' } });
await rejectSmsSender(1);
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/sms/senders/1/reject', {});
});
it('disableSmsSender(id) POSTs /api/admin/sms/senders/{id}/disable', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, status: 'suspended' } });
const r = await disableSmsSender(1);
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/sms/senders/1/disable');
expect(r.status).toBe('suspended');
});
});