Files
portal/app/tests/Frontend/client-sms-optouts.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

136 lines
5.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
/**
* Панель «Не писать этим» — стоп-лист самого клиента (строки листа 1.1–1.3).
*
* Человек вписал номер — номер в списке; удалил — исчез; загрузил файл —
* непонятые строки показаны образцами, а не проглочены молча.
*
* Сам API-модуль (адреса и CSRF) проверяется в `client-sms-api.spec.ts`:
* здесь модуль замокан целиком, поэтому проверять его тут нечем.
*/
vi.mock('../../resources/js/api/client', () => ({
apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
extractErrorMessage: vi.fn((_e: unknown, fallback: string) => fallback),
}));
const { fetchOptoutsMock, addOptoutsMock, uploadOptoutsFileMock, deleteOptoutMock } = vi.hoisted(
() => ({
fetchOptoutsMock: vi.fn(),
addOptoutsMock: vi.fn(),
uploadOptoutsFileMock: vi.fn(),
deleteOptoutMock: vi.fn(),
}),
);
vi.mock('../../resources/js/api/client-sms', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
fetchOptouts: fetchOptoutsMock,
addOptouts: addOptoutsMock,
uploadOptoutsFile: uploadOptoutsFileMock,
deleteOptout: deleteOptoutMock,
};
});
import SmsOptoutsPanel from '../../resources/js/components/advertising/SmsOptoutsPanel.vue';
function mountPanel() {
return mount(SmsOptoutsPanel, {
global: { plugins: [createVuetify()] },
});
}
describe('SmsOptoutsPanel — «Не писать этим»', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchOptoutsMock.mockResolvedValue([
{ id: 1, phone: '79991234567', source: 'client', note: 'просили на встрече' },
]);
addOptoutsMock.mockResolvedValue({ added: 1, rejected: 0, rejected_samples: [] });
uploadOptoutsFileMock.mockResolvedValue({ added: 0, rejected: 0, rejected_samples: [] });
deleteOptoutMock.mockResolvedValue(undefined);
});
it('показывает уже внесённые номера', async () => {
const wrapper = mountPanel();
await flushPromises();
expect(fetchOptoutsMock).toHaveBeenCalledOnce();
expect(wrapper.text()).toContain('79991234567');
expect(wrapper.text()).toContain('просили на встрече');
});
it('1.1 — вписал номер руками: номер уходит на сервер и появляется в списке', async () => {
const wrapper = mountPanel();
await flushPromises();
// Второй ответ сервера — список уже с добавленным номером.
fetchOptoutsMock.mockResolvedValue([
{ id: 1, phone: '79991234567', source: 'client', note: 'просили на встрече' },
{ id: 2, phone: '79997654321', source: 'client', note: null },
]);
await wrapper.find('[data-testid="sms-optout-phone-input"] input').setValue('79997654321');
await wrapper.find('[data-testid="sms-optout-add"]').trigger('click');
await flushPromises();
expect(addOptoutsMock).toHaveBeenCalledWith(['79997654321'], null);
expect(wrapper.text()).toContain('79997654321');
});
it('1.2 — удалил номер: он уходит из списка', async () => {
const wrapper = mountPanel();
await flushPromises();
fetchOptoutsMock.mockResolvedValue([]);
await wrapper.find('[data-testid="sms-optout-delete-1"]').trigger('click');
await flushPromises();
expect(deleteOptoutMock).toHaveBeenCalledWith(1);
expect(wrapper.text()).not.toContain('79991234567');
});
it('1.3 — загрузка файлом: непонятые строки показаны образцами, а не проглочены молча', async () => {
uploadOptoutsFileMock.mockResolvedValue({
added: 5,
rejected: 2,
rejected_samples: ['телефон отсутствует', '12345'],
});
const wrapper = mountPanel();
await flushPromises();
// Компонент читает файл из input[type=file]; в тесте зовём обработчик напрямую
// с готовым файлом — DataTransfer в jsdom неполноценен.
const file = new File(['x'], 'stop.xlsx');
await (wrapper.vm as unknown as { uploadFile: (f: File) => Promise<void> }).uploadFile(file);
await flushPromises();
expect(uploadOptoutsFileMock).toHaveBeenCalledWith(file, null);
expect(wrapper.text()).toContain('Добавлено: 5');
expect(wrapper.text()).toContain('Не поняли 2');
expect(wrapper.text()).toContain('телефон отсутствует');
expect(wrapper.text()).toContain('12345');
});
it('комментарий уходит вместе с пачкой номеров', async () => {
const wrapper = mountPanel();
await flushPromises();
await wrapper.find('[data-testid="sms-optout-phone-input"] input').setValue('79997654321');
await wrapper.find('[data-testid="sms-optout-note-input"] input').setValue('отказались');
await wrapper.find('[data-testid="sms-optout-add"]').trigger('click');
await flushPromises();
expect(addOptoutsMock).toHaveBeenCalledWith(['79997654321'], 'отказались');
});
});