df70a78e17
Затык был ровно один — routes/web.php: главная добавила ручку суточного итога сторожей с машины робота, ветка добавила приёмник статусов Билайна. Ручки независимые, оставлены обе. Попутно поправлено примечание в config/services.php: оно говорило «заявка подана, срок до десяти рабочих дней», а имя `Liderra.ru` было ОДОБРЕНО обеими сетями 10.08.2026 в тот же день — проверено глазами в кабинете. Примечание, которое врёт, хуже отсутствующего: следующая смена прочла бы его и не стала поднимать выключатель. Замер на сведённом коде: - модуль СМС 575/575, 7151 проверка (опора ветки была 548 — главная принесла 27); - фронтенд 2205 из 2210. Пять падений — ЧУЖИЕ и приехали из главной: KoshelyokZakladkiPoKanalam.spec.ts, экран AdWalletMoneyColumn.vue спотыкается об отсутствующее поле frozen_breakdown. Доказано отпечатками: и тест, и экран байт в байт совпадают с gitea/main, моя правка их не касалась; - Pint и PHPStan — чисто.
614 lines
27 KiB
TypeScript
614 lines
27 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import AdminSmsView from '../../resources/js/views/admin/AdminSmsView.vue';
|
||
import type { AdminSmsSender, AdminSmsTariffsResponse } from '../../resources/js/api/admin';
|
||
|
||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||
return {
|
||
...orig,
|
||
fetchSmsTariffs: vi.fn(),
|
||
updateSmsTariffs: vi.fn(),
|
||
updateSmsSettings: vi.fn(),
|
||
fetchSmsSenders: vi.fn(),
|
||
approveSmsSender: vi.fn(),
|
||
rejectSmsSender: vi.fn(),
|
||
disableSmsSender: vi.fn(),
|
||
// Этап 1: общий стоп-лист портала (строки листа 1.4, 1.5).
|
||
fetchSmsGlobalOptouts: vi.fn(),
|
||
addSmsGlobalOptout: vi.fn(),
|
||
deleteSmsGlobalOptout: vi.fn(),
|
||
// Этап 5 Task 8: сверка расчётов с оператором (строка листа 5.7).
|
||
fetchSmsReconcile: vi.fn(),
|
||
};
|
||
});
|
||
|
||
const adminApi = await import('../../resources/js/api/admin');
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
function makeTariffs(overrides: Partial<AdminSmsTariffsResponse> = {}): AdminSmsTariffsResponse {
|
||
return {
|
||
tariffs: [
|
||
{ id: 1, min_qty: 1, price_rub: '3.50' },
|
||
{ id: 2, min_qty: 1000, price_rub: '3.00' },
|
||
],
|
||
settings: {
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeSender(overrides: Partial<AdminSmsSender> = {}): AdminSmsSender {
|
||
return {
|
||
id: 1,
|
||
tenant_id: 5,
|
||
name: 'ОКНА МСК',
|
||
name_type: 'company',
|
||
status: 'pending',
|
||
monthly_fee_rub: '500.00',
|
||
note: null,
|
||
// Оба поля задаём явно: без них Partial<> подмешивал сюда undefined,
|
||
// и проверка типов считала образец неполным именем отправителя.
|
||
doc_original_name: null,
|
||
consent_doc_original_name: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const mountView = () =>
|
||
mount(AdminSmsView, {
|
||
global: { plugins: [createVuetify()] },
|
||
});
|
||
|
||
describe('AdminSmsView', () => {
|
||
it('на mount загружает тарифы, настройки и заявки на согласовании', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValueOnce(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([makeSender()]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchSmsTariffs).toHaveBeenCalledTimes(1);
|
||
expect(adminApi.fetchSmsSenders).toHaveBeenCalledWith('pending');
|
||
|
||
expect(wrapper.text()).toContain('ОКНА МСК');
|
||
expect(wrapper.find('[data-testid="sms-tariffs-save"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="sms-settings-save"]').exists()).toBe(true);
|
||
});
|
||
|
||
it('«Сохранить тарифы» вызывает updateSmsTariffs с текущими строками', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsTariffs).mockResolvedValueOnce({ tariffs: makeTariffs().tariffs });
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="sms-tariffs-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsTariffs).toHaveBeenCalledTimes(1);
|
||
const rows = vi.mocked(adminApi.updateSmsTariffs).mock.calls[0][0];
|
||
expect(rows).toEqual([
|
||
{ min_qty: 1, price_rub: '3.50' },
|
||
{ min_qty: 1000, price_rub: '3.00' },
|
||
]);
|
||
});
|
||
|
||
it('«Сохранить настройки» вызывает updateSmsSettings с полями', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsSettings).mockResolvedValueOnce({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="sms-settings-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsSettings).toHaveBeenCalledWith({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
// 10.08.2026 сетей стало ПЯТЬ: добавился Мотив. Это умолчание экрана,
|
||
// когда сервер списка не прислал, — оно обязано совпадать с
|
||
// AllowedSmsOperators::DEFAULT, иначе первое же сохранение настроек
|
||
// молча ВЫЧЕРКНУЛО бы Мотива обратно.
|
||
allowed_operators: ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
|
||
});
|
||
});
|
||
|
||
it('показывает границы окна отправки и сохраняет изменённые (строка листа 3.7)', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(
|
||
makeTariffs({
|
||
settings: {
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 11,
|
||
quiet_to_hour: 19,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsSettings).mockResolvedValueOnce({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 12,
|
||
quiet_to_hour: 18,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
// Пришедшие с сервера границы видны человеку в полях.
|
||
const from = wrapper.find('[data-testid="sms-settings-quiet-from"] input')
|
||
.element as HTMLInputElement;
|
||
const to = wrapper.find('[data-testid="sms-settings-quiet-to"] input').element as HTMLInputElement;
|
||
expect(from.value).toBe('11');
|
||
expect(to.value).toBe('19');
|
||
|
||
// Владелец правит окно и сохраняет — уходит именно то, что он ввёл.
|
||
await wrapper.find('[data-testid="sms-settings-quiet-from"] input').setValue('12');
|
||
await wrapper.find('[data-testid="sms-settings-quiet-to"] input').setValue('18');
|
||
await wrapper.find('[data-testid="sms-settings-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsSettings).toHaveBeenCalledWith({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 12,
|
||
quiet_to_hour: 18,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
|
||
});
|
||
});
|
||
|
||
it('4.3 — показывает срок «рассылка зависла» и сохраняет изменённый', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(
|
||
makeTariffs({
|
||
settings: {
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 120,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsSettings).mockResolvedValueOnce({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 180,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: null,
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
// Пришедший с сервера срок виден человеку в поле — экран его не выдумывает.
|
||
const field = wrapper.find('[data-testid="sms-settings-stuck-minutes"] input')
|
||
.element as HTMLInputElement;
|
||
expect(field.value).toBe('120');
|
||
|
||
// Владелец правит срок и сохраняет — уходит именно то, что он ввёл.
|
||
await wrapper.find('[data-testid="sms-settings-stuck-minutes"] input').setValue('180');
|
||
await wrapper.find('[data-testid="sms-settings-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsSettings).toHaveBeenCalledWith({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 180,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
|
||
});
|
||
});
|
||
|
||
it('4.6 — показывает потолок загрузки базы и сохраняет изменённый', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(
|
||
makeTariffs({
|
||
settings: {
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 20000,
|
||
allowed_operators: null,
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsSettings).mockResolvedValueOnce({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 30000,
|
||
allowed_operators: null,
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
// Пришедший с сервера потолок виден человеку в поле — экран его не выдумывает.
|
||
const field = wrapper.find('[data-testid="sms-settings-max-upload"] input')
|
||
.element as HTMLInputElement;
|
||
expect(field.value).toBe('20000');
|
||
|
||
// Владелец правит потолок и сохраняет — уходит именно то, что он ввёл.
|
||
await wrapper.find('[data-testid="sms-settings-max-upload"] input').setValue('30000');
|
||
await wrapper.find('[data-testid="sms-settings-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsSettings).toHaveBeenCalledWith({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 30000,
|
||
allowed_operators: ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
|
||
});
|
||
});
|
||
|
||
it('4.14 — показывает список «кому шлём» с сервера и сохраняет изменённый', async () => {
|
||
// Строка листа 4.14: список — настройка, а не код. Владелец снимает
|
||
// галочку, и отбор получателей меняется без выката.
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(
|
||
makeTariffs({
|
||
settings: {
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: ['mts', 'megafon'],
|
||
},
|
||
}),
|
||
);
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
vi.mocked(adminApi.updateSmsSettings).mockResolvedValueOnce({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: ['mts'],
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
// Пришедший с сервера список виден в галочках — экран его не выдумывает.
|
||
const megafon = wrapper.find('[data-testid="sms-settings-operator-megafon"] input')
|
||
.element as HTMLInputElement;
|
||
const beeline = wrapper.find('[data-testid="sms-settings-operator-beeline"] input')
|
||
.element as HTMLInputElement;
|
||
expect(megafon.checked).toBe(true);
|
||
expect(beeline.checked).toBe(false);
|
||
|
||
// Владелец снимает Мегафон — уходит ровно то, что осталось отмеченным.
|
||
await wrapper.find('[data-testid="sms-settings-operator-megafon"] input').setValue(false);
|
||
await wrapper.find('[data-testid="sms-settings-save"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.updateSmsSettings).toHaveBeenCalledWith({
|
||
name_fee_rub_per_operator: '500.00',
|
||
name_debt_grace_days: 5,
|
||
quiet_from_hour: 10,
|
||
quiet_to_hour: 20,
|
||
stuck_after_minutes: 60,
|
||
max_upload_phones: 50000,
|
||
allowed_operators: ['mts'],
|
||
});
|
||
});
|
||
|
||
it('4.3 — человеку объясняют, что будет с зависшей рассылкой и с деньгами', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
const text = wrapper.text();
|
||
expect(text).toContain('сначала попробует её дожать');
|
||
expect(text).toContain('вернёт клиенту замороженные деньги');
|
||
// И что честно ждущую утра рассылку это не трогает (строка 4.2).
|
||
expect(text).toContain('честно ждёт утра');
|
||
});
|
||
|
||
it('человеку объясняют, что окно общее и в местном времени получателя', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValue([]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.text()).toContain('местному времени');
|
||
expect(wrapper.text()).toContain('Клиент это время не настраивает');
|
||
});
|
||
|
||
it('«Подтвердить» вызывает approveSmsSender(id) и перезагружает список', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValue(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders)
|
||
.mockResolvedValueOnce([makeSender({ id: 7 })])
|
||
.mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.approveSmsSender).mockResolvedValueOnce(makeSender({ id: 7, status: 'active' }));
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="sms-sender-approve-7"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.approveSmsSender).toHaveBeenCalledWith(7);
|
||
expect(adminApi.fetchSmsSenders).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
// ─── Общий стоп-лист портала (строки листа 1.4, 1.5) ────────────────────
|
||
|
||
it('1.5 — показывает внесённые номера с причиной', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValueOnce(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.fetchSmsGlobalOptouts).mockResolvedValueOnce([
|
||
{ phone: '79991234567', reason: 'жалоба в МТС', created_at: '2026-07-27T10:00:00Z' },
|
||
]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(adminApi.fetchSmsGlobalOptouts).toHaveBeenCalledTimes(1);
|
||
expect(wrapper.text()).toContain('79991234567');
|
||
expect(wrapper.text()).toContain('жалоба в МТС');
|
||
});
|
||
|
||
it('1.4 — вносит номер с комментарием и перечитывает список', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValueOnce(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.fetchSmsGlobalOptouts)
|
||
.mockResolvedValueOnce([])
|
||
.mockResolvedValueOnce([
|
||
{ phone: '79991234567', reason: 'жалоба в МТС', created_at: '2026-07-27T10:00:00Z' },
|
||
]);
|
||
vi.mocked(adminApi.addSmsGlobalOptout).mockResolvedValueOnce({
|
||
phone: '79991234567',
|
||
reason: 'жалоба в МТС',
|
||
created_at: '2026-07-27T10:00:00Z',
|
||
});
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="global-optout-phone"] input').setValue('79991234567');
|
||
await wrapper.find('[data-testid="global-optout-reason"] input').setValue('жалоба в МТС');
|
||
await wrapper.find('[data-testid="global-optout-add"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.addSmsGlobalOptout).toHaveBeenCalledWith('79991234567', 'жалоба в МТС');
|
||
expect(adminApi.fetchSmsGlobalOptouts).toHaveBeenCalledTimes(2);
|
||
expect(wrapper.text()).toContain('79991234567');
|
||
});
|
||
|
||
it('1.5 — убирает номер из общего стоп-листа', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValueOnce(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.fetchSmsGlobalOptouts)
|
||
.mockResolvedValueOnce([
|
||
{ phone: '79991234567', reason: null, created_at: '2026-07-27T10:00:00Z' },
|
||
])
|
||
.mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.deleteSmsGlobalOptout).mockResolvedValueOnce(undefined);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
await wrapper.find('[data-testid="global-optout-delete-79991234567"]').trigger('click');
|
||
await flushPromises();
|
||
|
||
expect(adminApi.deleteSmsGlobalOptout).toHaveBeenCalledWith('79991234567');
|
||
expect(wrapper.text()).not.toContain('79991234567');
|
||
});
|
||
|
||
it('ошибка загрузки тарифов показывает человеческое сообщение', async () => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockRejectedValueOnce(new Error('500'));
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([]);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-testid="sms-tariffs-error"]').exists()).toBe(true);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Строка листа 5.7 — сверка расчётов, экран владельца.
|
||
*
|
||
* 🔴 Главное, что здесь охраняется: экран НИКОГДА не выдаёт отсутствие числа
|
||
* за ноль. Живая проба 31.07 (В-235) шесть раз получила от МТС цену 0, потому
|
||
* что он ничего не отправил, а наша цена канала на бою вообще не задана
|
||
* (В-234) — нарисуй мы «0 ₽ против 0 ₽», владелец увидел бы идеальную
|
||
* сходимость там, где сверять нечем.
|
||
*
|
||
* 🪤 Целимся в КЛЕТКУ строки, а не в текст всей страницы: «не сообщил»
|
||
* содержится и в других подписях, и проверка по всей странице зеленела бы зря.
|
||
*/
|
||
describe('AdminSmsView — сверка расчётов с оператором', () => {
|
||
function makeReconcileRow(
|
||
overrides: Partial<import('../../resources/js/api/admin').AdminSmsReconcileRow> = {},
|
||
): import('../../resources/js/api/admin').AdminSmsReconcileRow {
|
||
return {
|
||
campaign_id: 7,
|
||
tenant_id: 3,
|
||
title: 'Летняя рассылка',
|
||
created_at: '2026-07-31T09:00:00Z',
|
||
messages: 2,
|
||
messages_reported: 2,
|
||
our_parts: 3,
|
||
provider_parts: 3,
|
||
parts_mismatch: 0,
|
||
our_cost: '9.0000',
|
||
our_cost_reported: '9.0000',
|
||
our_price_known: true,
|
||
provider_cost: '10.0000',
|
||
provider_cost_known: true,
|
||
difference: '1.0000',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const mountWithReconcile = async (
|
||
rows: import('../../resources/js/api/admin').AdminSmsReconcileRow[],
|
||
) => {
|
||
vi.mocked(adminApi.fetchSmsTariffs).mockResolvedValueOnce(makeTariffs());
|
||
vi.mocked(adminApi.fetchSmsSenders).mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.fetchSmsGlobalOptouts).mockResolvedValueOnce([]);
|
||
vi.mocked(adminApi.fetchSmsReconcile).mockResolvedValueOnce(rows);
|
||
|
||
const wrapper = mountView();
|
||
await flushPromises();
|
||
|
||
return wrapper;
|
||
};
|
||
|
||
it('на mount загружает сверку и показывает оба счёта частей и разницу', async () => {
|
||
const wrapper = await mountWithReconcile([makeReconcileRow()]);
|
||
|
||
expect(adminApi.fetchSmsReconcile).toHaveBeenCalled();
|
||
|
||
const row = wrapper.find('[data-testid="sms-reconcile-row-7"]');
|
||
expect(row.exists()).toBe(true);
|
||
expect(row.text()).toContain('Летняя рассылка');
|
||
expect(row.text()).toContain('9.00 ₽');
|
||
expect(row.text()).toContain('10.00 ₽');
|
||
expect(wrapper.find('[data-testid="sms-reconcile-diff-7"]').text()).toBe('1.00 ₽');
|
||
});
|
||
|
||
it('расхождение по частям выделено отдельно, а совпадение — нет', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({ campaign_id: 7, parts_mismatch: 2 }),
|
||
makeReconcileRow({ campaign_id: 8, parts_mismatch: 0 }),
|
||
]);
|
||
|
||
expect(wrapper.find('[data-testid="sms-reconcile-mismatch-7"]').text()).toBe('2');
|
||
expect(wrapper.find('[data-testid="sms-reconcile-mismatch-8"]').exists()).toBe(false);
|
||
});
|
||
|
||
it('оператор цену не сообщил — экран пишет причину, а не 0 ₽', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({
|
||
provider_cost: null,
|
||
provider_cost_known: false,
|
||
difference: null,
|
||
}),
|
||
]);
|
||
|
||
const row = wrapper.find('[data-testid="sms-reconcile-row-7"]');
|
||
expect(row.text()).toContain('оператор цену не сообщил');
|
||
expect(row.text()).not.toContain('0.00 ₽');
|
||
expect(wrapper.find('[data-testid="sms-reconcile-diff-7"]').text()).toBe('сверять нечем');
|
||
});
|
||
|
||
it('наша цена канала не задана — экран пишет причину, а не 0 ₽', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({
|
||
our_cost: null,
|
||
our_price_known: false,
|
||
difference: null,
|
||
}),
|
||
]);
|
||
|
||
const row = wrapper.find('[data-testid="sms-reconcile-row-7"]');
|
||
expect(row.text()).toContain('цена канала не задана');
|
||
expect(wrapper.find('[data-testid="sms-reconcile-diff-7"]').text()).toBe('сверять нечем');
|
||
});
|
||
|
||
// Находка приёмки Этапа 5: без охвата число «разница» непонятно — человек не
|
||
// видит, посчитана она по всей рассылке или по десятой её части.
|
||
it('оператор отчитался не по всем — рядом с разницей видно, по скольким', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({
|
||
messages: 120,
|
||
messages_reported: 10,
|
||
our_cost: '360.0000',
|
||
our_cost_reported: '30.0000',
|
||
provider_cost: '30.0000',
|
||
difference: '0.0000',
|
||
}),
|
||
]);
|
||
|
||
expect(wrapper.find('[data-testid="sms-reconcile-coverage-7"]').text()).toContain(
|
||
'оператор отчитался по 10 из 120',
|
||
);
|
||
});
|
||
|
||
it('оператор отчитался по всем — про охват не пишем, чтобы не шуметь', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({
|
||
messages: 120,
|
||
messages_reported: 120,
|
||
our_cost: '360.0000',
|
||
our_cost_reported: '360.0000',
|
||
provider_cost: '360.0000',
|
||
difference: '0.0000',
|
||
}),
|
||
]);
|
||
|
||
expect(wrapper.find('[data-testid="sms-reconcile-coverage-7"]').exists()).toBe(false);
|
||
});
|
||
|
||
it('оператора ещё не спрашивали — вместо нуля частей так и написано', async () => {
|
||
const wrapper = await mountWithReconcile([
|
||
makeReconcileRow({ provider_parts: null, parts_mismatch: 0 }),
|
||
]);
|
||
|
||
expect(wrapper.find('[data-testid="sms-reconcile-row-7"]').text()).toContain('не спрашивали');
|
||
});
|
||
|
||
it('отправленных рассылок нет — экран говорит об этом словами', async () => {
|
||
const wrapper = await mountWithReconcile([]);
|
||
|
||
expect(wrapper.find('[data-testid="sms-reconcile-empty"]').exists()).toBe(true);
|
||
});
|
||
});
|