37493b7d02
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками, пачкой из файла, удаление; непонятые строки показаны образцами, а не молча. Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги фактические, а не смета. Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами сервера и повторяет только по согласию человека. В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление. Отказ получателя (страница по ссылке и приписка в тексте) в экранах отсутствует — отменён владельцем, см. В-30 приёмочного листа. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
5.8 KiB
TypeScript
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'], 'отказались');
|
|
});
|
|
});
|