22ac6e4f13
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и человек стал открывать его чаще. Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель. На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы возвращаемся туда, откуда ушли. Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили живьём 30 июля на базе номеров. Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на сервере, и на экране. Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать. Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд возвращён. Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт, а не то поле, которое ты ждал. Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы. Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали — просим первую.
418 lines
18 KiB
TypeScript
418 lines
18 KiB
TypeScript
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');
|
||
});
|
||
});
|