Files
portal/app/tests/Frontend/client-sms-api.spec.ts
T
Дмитрий 22ac6e4f13 feat(смс-клиент): журнал рассылки открывается страницами по 50, а не одним куском
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная
разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч
номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул
из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и
человек стал открывать его чаще.

Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель.
На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер
страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы
возвращаемся туда, откуда ушли.

Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего
запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили
живьём 30 июля на базе номеров.

Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются
по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и
не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице
значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на
сервере, и на экране.

Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать.
Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит
вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом
другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не
доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд
возвращён.

Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и
не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт,
а не то поле, которое ты ждал.

Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы.
Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали —
просим первую.
2026-08-01 10:37:50 +03:00

418 lines
18 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(),
patch: vi.fn(),
delete: vi.fn(),
},
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
}));
import {
fetchClientSms,
previewClientSms,
createClientSms,
fetchClientSmsCampaign,
fetchContacts,
uploadContacts,
uploadContactsFile,
contactsExampleUrl,
deleteContact,
fetchTemplates,
saveTemplate,
updateTemplate,
deleteTemplate,
fetchSender,
requestSender,
disableSender,
fetchAutoRule,
saveAutoRule,
fetchOptouts,
addOptouts,
uploadOptoutsFile,
deleteOptout,
cancelClientSms,
} from '../../resources/js/api/client-sms';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
describe('api/client-sms', () => {
beforeEach(() => vi.clearAllMocks());
it('fetchClientSms() GETs /api/sms/campaigns and unwraps', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { campaigns: [], sandbox: true, sender_name: 'LIDERRA' },
});
const res = await fetchClientSms();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/campaigns');
expect(res.sandbox).toBe(true);
expect(res.sender_name).toBe('LIDERRA');
expect(res.campaigns).toEqual([]);
});
it('previewClientSms() ensures csrf then POSTs /api/sms/preview with the payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
segments: 1,
sendable_count: 10,
skipped: { no_phone: 2 },
estimated_cost_rub: '25.00',
price_rub_per_sms: '2.50',
},
});
const payload = { body: 'Привет', source: 'deals' as const, audience_days: 30 };
const res = await previewClientSms(payload);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/preview', payload);
expect(res.sendable_count).toBe(10);
expect(res.skipped).toEqual({ no_phone: 2 });
});
it('createClientSms() ensures csrf then POSTs /api/sms/campaigns with payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
id: 1,
title: 'Акция',
body: 'Текст',
sender_name: 'LIDERRA',
source: 'deals',
audience_days: 30,
status: 'draft',
segments: 1,
planned_count: 10,
sent_count: 0,
total_sms: 10,
price_rub_per_sms: '2.50',
estimated_cost_rub: '25.00',
actual_cost_rub: null,
},
});
const payload = { title: 'Акция', body: 'Текст', source: 'deals' as const, audience_days: 30 };
const c = await createClientSms(payload);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/campaigns', payload);
expect(c.id).toBe(1);
expect(c.status).toBe('draft');
});
it('fetchClientSmsCampaign(id) GETs /api/sms/campaigns/{id} and returns campaign+messages', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: {
campaign: {
id: 3,
title: 'X',
body: 'Y',
sender_name: 'LIDERRA',
source: 'base',
audience_days: null,
status: 'sent',
segments: 1,
planned_count: 5,
sent_count: 5,
total_sms: 5,
price_rub_per_sms: '2.50',
estimated_cost_rub: '12.50',
actual_cost_rub: '12.50',
},
messages: [],
messages_page: { total: 120, page: 2, per_page: 50, last_page: 3 },
},
});
// Этап 5, Task 10: журнал отдаётся СТРАНИЦАМИ (решение владельца В-203),
// и номер страницы обязан доехать до сервера — иначе человек листает, а
// видит одно и то же (форма дыры В-167).
const res = await fetchClientSmsCampaign(3, 2);
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/campaigns/3', { params: { page: 2 } });
expect(res.campaign.id).toBe(3);
expect(res.messages).toEqual([]);
expect(res.messages_page?.total).toBe(120);
expect(res.messages_page?.page).toBe(2);
});
it('fetchClientSmsCampaign(id) без номера страницы просит ПЕРВУЮ', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { campaign: { id: 3 }, messages: [] },
});
await fetchClientSmsCampaign(3);
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/campaigns/3', { params: { page: 1 } });
});
it('fetchContacts() GETs /api/sms/contacts со страницей', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: {
items: [{ id: 1, phone: '79001234567', name: null, operator: 'МТС' }],
max_upload_phones: 50000,
total: 150,
page: 2,
per_page: 50,
last_page: 3,
},
});
const res = await fetchContacts(2);
// Номер страницы уходит НА СЕРВЕР: резать список на экране нельзя, иначе
// ответ по-прежнему тянет всю базу целиком (строка листа 4.7).
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/contacts', { params: { page: 2 } });
expect(res.items).toHaveLength(1);
expect(res.items[0].phone).toBe('79001234567');
// Потолок загрузки приезжает вместе со списком (строка листа 4.6).
expect(res.max_upload_phones).toBe(50000);
expect(res.total).toBe(150);
expect(res.last_page).toBe(3);
});
it('uploadContacts() ensures csrf then POSTs /api/sms/contacts with {phones}', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { added: 2, contacts: [{ id: 1, phone: '79001234567', name: null, operator: null }] },
});
const res = await uploadContacts(['79001234567', '79007654321']);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/contacts', { phones: ['79001234567', '79007654321'] });
expect(res.added).toBe(2);
});
it('uploadContactsFile() POSTs multipart to /api/sms/contacts/file', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { added: 3, rejected: 1, rejected_samples: ['мусор'], contacts: [] },
});
const file = new File(['x'], 'baza.xlsx');
const res = await uploadContactsFile(file);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
const [url, form] = vi.mocked(apiClient.post).mock.calls[0];
expect(url).toBe('/api/sms/contacts/file');
expect(form).toBeInstanceOf(FormData);
expect((form as FormData).get('file')).toBe(file);
expect(res.rejected_samples).toEqual(['мусор']);
});
it('contactsExampleUrl() возвращает адрес примера', () => {
expect(contactsExampleUrl()).toBe('/api/sms/contacts/example');
});
it('deleteContact(id) ensures csrf then DELETEs /api/sms/contacts/{id}', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteContact(9);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.delete).toHaveBeenCalledWith('/api/sms/contacts/9');
});
it('fetchTemplates() GETs /api/sms/templates', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [{ id: 1, title: 'T', body: 'B' }] });
const res = await fetchTemplates();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/templates');
expect(res[0].title).toBe('T');
});
it('saveTemplate() ensures csrf then POSTs /api/sms/templates', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 2, title: 'T2', body: 'B2' } });
const res = await saveTemplate('T2', 'B2');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/templates', { title: 'T2', body: 'B2' });
expect(res.id).toBe(2);
});
it('updateTemplate() ensures csrf then PATCHes /api/sms/templates/{id}', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 2, title: 'T3', body: 'B3' } });
const res = await updateTemplate(2, 'T3', 'B3');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.patch).toHaveBeenCalledWith('/api/sms/templates/2', { title: 'T3', body: 'B3' });
expect(res.body).toBe('B3');
});
it('deleteTemplate(id) ensures csrf then DELETEs /api/sms/templates/{id}', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTemplate(2);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.delete).toHaveBeenCalledWith('/api/sms/templates/2');
});
// ─── Этап 2: своё имя отправителя + авто-СМС ────────────────────────────
it('fetchSender() GETs /api/sms/sender and unwraps sender/effective_name/fee', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: {
sender: {
id: 7,
name: 'MYSHOP',
name_type: 'company',
status: 'active',
monthly_fee_rub: '300.00',
note: null,
paid_until: '2026-08-25',
},
effective_name: 'MYSHOP',
name_fee_rub_per_operator: '100.00',
},
});
const res = await fetchSender();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/sender');
expect(res.sender?.name).toBe('MYSHOP');
expect(res.sender?.status).toBe('active');
expect(res.effective_name).toBe('MYSHOP');
expect(res.name_fee_rub_per_operator).toBe('100.00');
});
it('fetchSender() handles null sender', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { sender: null, effective_name: 'liderra.ru', name_fee_rub_per_operator: '100.00' },
});
const res = await fetchSender();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/sender');
expect(res.sender).toBeNull();
expect(res.effective_name).toBe('liderra.ru');
});
it('requestSender() ensures csrf then POSTs multipart FormData с ДВУМЯ файлами (согласие + основание)', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
id: 5,
name: 'liderra.ru',
name_type: 'website',
status: 'pending',
monthly_fee_rub: '300.00',
note: null,
paid_until: null,
doc_original_name: 'ogrn.pdf',
consent_doc_original_name: 'soglasie.pdf',
},
});
const consent = new File(['%PDF-consent'], 'soglasie.pdf', { type: 'application/pdf' });
const basis = new File(['%PDF-fake'], 'ogrn.pdf', { type: 'application/pdf' });
const res = await requestSender('liderra.ru', 'website', consent, basis);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
// Отправляем multipart FormData с обоими обязательными сканами.
const [url, body] = vi.mocked(apiClient.post).mock.calls[0];
expect(url).toBe('/api/sms/sender');
expect(body).toBeInstanceOf(FormData);
const form = body as FormData;
expect(form.get('name')).toBe('liderra.ru');
expect(form.get('name_type')).toBe('website');
expect(form.get('consent_document')).toBeInstanceOf(File);
expect((form.get('consent_document') as File).name).toBe('soglasie.pdf');
expect(form.get('document')).toBeInstanceOf(File);
expect((form.get('document') as File).name).toBe('ogrn.pdf');
expect(res.id).toBe(5);
expect(res.status).toBe('pending');
});
it('consentFormUrl() строит ссылку на бланк с name и name_type', async () => {
const { consentFormUrl } = await import('../../resources/js/api/client-sms');
const url = consentFormUrl('MYSHOP', 'legal');
expect(url).toContain('/api/sms/sender/consent-form?');
expect(url).toContain('name=MYSHOP');
expect(url).toContain('name_type=legal');
// Без правообладателя-физлица owner_* не добавляются.
expect(url).not.toContain('owner_type');
});
it('consentFormUrl() добавляет owner_type/owner_name для домена на физлице', async () => {
const { consentFormUrl } = await import('../../resources/js/api/client-sms');
const url = consentFormUrl('shop.ru', 'website', 'individual', 'Иван Петров');
expect(url).toContain('name_type=website');
expect(url).toContain('owner_type=individual');
expect(url).toContain(encodeURIComponent('Иван Петров').replace(/%20/g, '+'));
});
it('disableSender() ensures csrf then POSTs /api/sms/sender/disable', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
disabled: true,
sender: {
id: 5,
name: 'MYSHOP',
name_type: 'company',
status: 'cancelled',
monthly_fee_rub: '300.00',
note: null,
paid_until: null,
},
},
});
const res = await disableSender();
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/sender/disable');
expect(res.disabled).toBe(true);
expect(res.sender.status).toBe('cancelled');
});
it('fetchAutoRule() GETs /api/sms/auto-rule', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { enabled: true, body: 'Спасибо за заявку!', sender_name: 'MYSHOP' },
});
const res = await fetchAutoRule();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/auto-rule');
expect(res.enabled).toBe(true);
expect(res.body).toBe('Спасибо за заявку!');
expect(res.sender_name).toBe('MYSHOP');
});
it('saveAutoRule() ensures csrf then POSTs /api/sms/auto-rule with {enabled,body}', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { enabled: true, body: 'Здравствуйте!', sender_name: 'MYSHOP' },
});
const res = await saveAutoRule(true, 'Здравствуйте!');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/auto-rule', {
enabled: true,
body: 'Здравствуйте!',
});
expect(res.enabled).toBe(true);
expect(res.body).toBe('Здравствуйте!');
});
// ─── Этап 1: «Не писать этим» + остановка рассылки ───────────────────────
it('fetchOptouts() GET /api/sms/optouts и разворачивает items', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { items: [{ id: 1, phone: '79991234567', source: 'client', note: null }] },
});
const res = await fetchOptouts();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/optouts');
expect(res).toHaveLength(1);
expect(res[0].phone).toBe('79991234567');
});
it('addOptouts() берёт CSRF и POST /api/sms/optouts с номерами и комментарием', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { added: 2, rejected: 0, rejected_samples: [] },
});
const res = await addOptouts(['79991234567', '79991234568'], 'просили на встрече');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/optouts', {
phones: ['79991234567', '79991234568'],
note: 'просили на встрече',
});
expect(res.added).toBe(2);
});
it('uploadOptoutsFile() шлёт файл формой на /api/sms/optouts/file', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { added: 5, rejected: 1, rejected_samples: ['мусор'] },
});
const file = new File(['x'], 'stop.xlsx');
const res = await uploadOptoutsFile(file, 'из старой базы');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
const [url, form] = vi.mocked(apiClient.post).mock.calls[0];
expect(url).toBe('/api/sms/optouts/file');
expect(form).toBeInstanceOf(FormData);
expect((form as FormData).get('file')).toBe(file);
expect((form as FormData).get('note')).toBe('из старой базы');
expect(res.rejected_samples).toEqual(['мусор']);
});
it('deleteOptout() берёт CSRF и DELETE /api/sms/optouts/{id}', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: { deleted: true } });
await deleteOptout(7);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.delete).toHaveBeenCalledWith('/api/sms/optouts/7');
});
it('cancelClientSms() берёт CSRF и POST /api/sms/campaigns/{id}/cancel', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { ok: true } });
await cancelClientSms(12);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/sms/campaigns/12/cancel');
});
});