Files
portal/app/tests/Frontend/client-sms-api.spec.ts
T
Дмитрий 37493b7d02 feat(смс-клиент): экраны Этапа 1 — «Не писать этим», «Остановить», ключ заказа, общий стоп-лист в админке
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками,
пачкой из файла, удаление; непонятые строки показаны образцами, а не молча.

Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после
остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги
фактические, а не смета.

Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после
успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами
сервера и повторяет только по согласию человека.

В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление.

Отказ получателя (страница по ссылке и приписка в тексте) в экранах
отсутствует — отменён владельцем, см. В-30 приёмочного листа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:45:47 +03:00

391 lines
17 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: [],
},
});
const res = await fetchClientSmsCampaign(3);
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/campaigns/3');
expect(res.campaign.id).toBe(3);
expect(res.messages).toEqual([]);
});
it('fetchContacts() GETs /api/sms/contacts', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: [{ id: 1, phone: '79001234567', name: null, operator: 'МТС' }],
});
const res = await fetchContacts();
expect(apiClient.get).toHaveBeenCalledWith('/api/sms/contacts');
expect(res).toHaveLength(1);
expect(res[0].phone).toBe('79001234567');
});
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');
});
});