feat(реклама Яндекс): переиспользование черновика, удаление черновика, загрузка своего списка номеров

T14 — мастер запуска рекламы без campaignId сперва смотрит список кампаний
и продолжает последний незапущенный черновик вместо создания нового на
каждый заход (сбой списка — фолбэк на прежнее createDraft). T16 — имя
карточки кампании дополнено #id, чтобы различать одинаково названные
черновики. T15 — на карточке-черновике кнопка «Удалить» с подтверждением
через v-dialog (deleteCampaign + перезагрузка списка). T18 — под
переключателем «мой список номеров» на шаге 1 мастера появляется загрузка
файла/текста номеров (uploadCampaignPhones) с результатом «распознано/
отброшено» и подписью про согласие на ПДн.

api/advertising.ts: deleteCampaign(id), uploadCampaignPhones(id, {file,text}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-25 21:45:09 +03:00
parent cd77d32fc3
commit a8b13de9e0
6 changed files with 550 additions and 17 deletions
+28
View File
@@ -156,6 +156,34 @@ export async function uploadCreativeImage(id: number, adId: number, file: File):
return data;
}
/** DELETE /api/advertising/campaigns/{id} — удалить ЧЕРНОВИК кампании своего тенанта (409 если не draft, 404 чужой). */
export async function deleteCampaign(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/advertising/campaigns/${id}`);
}
/** Ответ POST /api/advertising/campaigns/{id}/phones — сколько номеров распознано / отброшено. */
export interface UploadPhonesResult {
recognized: number;
skipped: number;
}
/**
* POST /api/advertising/campaigns/{id}/phones — загрузить «мой список номеров» кампании
* (файл csv/txt и/или текст, оба необязательны по отдельности — но хотя бы один нужен серверу).
*/
export async function uploadCampaignPhones(
id: number,
payload: { file?: File | null; text?: string },
): Promise<UploadPhonesResult> {
await ensureCsrfCookie();
const form = new FormData();
if (payload.file) form.append('file', payload.file);
if (payload.text && payload.text.trim() !== '') form.append('text', payload.text);
const { data } = await apiClient.post<UploadPhonesResult>(`/api/advertising/campaigns/${id}/phones`, form);
return data;
}
/**
* Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising).
* Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) —
@@ -8,12 +8,21 @@
* зависят от текущего статуса (pauseCampaign/resumeCampaign), «Отчёт» —
* открывает CampaignReportDialog. «Изменить» пробрасывает id наверх —
* понадобится мастеру редактирования (B2-9).
*
* T16 — черновики называются одинаково («Реклама от ДД.ММ.ГГГГ»), поэтому в
* заголовке карточки к имени добавляется `#id`, чтобы различать несколько
* черновиков в списке.
*
* T15 — у черновика (status === 'draft') есть кнопка «Удалить»: подтверждение
* через `v-dialog`, затем `deleteCampaign` + перезагрузка списка. Для не-
* черновиков кнопки нет (сервер и не даст удалить — только draft, 409 иначе).
*/
import { ref, onMounted } from 'vue';
import {
fetchCampaigns,
pauseCampaign,
resumeCampaign,
deleteCampaign,
type Campaign,
type CampaignStatus,
} from '../../api/advertising';
@@ -118,6 +127,37 @@ function openReport(campaign: Campaign): void {
reportDialogOpen.value = true;
}
// T15 — подтверждение удаления черновика.
const deleteConfirmOpen = ref(false);
const campaignToDelete = ref<Campaign | null>(null);
function askDelete(campaign: Campaign): void {
campaignToDelete.value = campaign;
deleteConfirmOpen.value = true;
}
function cancelDelete(): void {
deleteConfirmOpen.value = false;
campaignToDelete.value = null;
}
async function confirmDelete(): Promise<void> {
const campaign = campaignToDelete.value;
if (!campaign) return;
deleteConfirmOpen.value = false;
busyId.value = campaign.id;
actionError.value = null;
try {
await deleteCampaign(campaign.id);
await loadCampaigns();
} catch (e) {
actionError.value = extractErrorMessage(e, 'Не удалось удалить кампанию.');
} finally {
busyId.value = null;
campaignToDelete.value = null;
}
}
defineExpose({ loadCampaigns });
</script>
@@ -175,7 +215,9 @@ defineExpose({ loadCampaigns });
<v-col v-for="campaign in campaigns" :key="campaign.id" cols="12" sm="6" lg="4">
<v-card variant="outlined" class="pa-4 h-100" data-testid="campaign-card">
<div class="d-flex align-center justify-space-between ga-2 mb-2">
<span class="text-subtitle-1 font-weight-medium">{{ campaign.name }}</span>
<span class="text-subtitle-1 font-weight-medium" data-testid="campaign-name"
>{{ campaign.name }} #{{ campaign.id }}</span
>
</div>
<v-chip
@@ -249,6 +291,19 @@ defineExpose({ loadCampaigns });
>
Отчёт
</v-btn>
<v-btn
v-if="campaign.status === 'draft'"
size="small"
variant="text"
color="error"
prepend-icon="mdi-delete-outline"
:loading="busyId === campaign.id"
data-testid="campaign-delete-btn"
@click="askDelete(campaign)"
>
Удалить
</v-btn>
</div>
</v-card>
</v-col>
@@ -256,6 +311,28 @@ defineExpose({ loadCampaigns });
</template>
<CampaignReportDialog v-model="reportDialogOpen" :campaign-id="reportCampaignId" />
<v-dialog v-model="deleteConfirmOpen" max-width="420" data-testid="campaign-delete-confirm-dialog">
<v-card>
<v-card-title class="text-subtitle-1">Удалить кампанию?</v-card-title>
<v-card-text class="text-body-2">
Черновик «{{ campaignToDelete?.name }} #{{ campaignToDelete?.id }}» будет удалён без возможности
восстановления.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" data-testid="campaign-delete-cancel" @click="cancelDelete">Отмена</v-btn>
<v-btn
color="error"
variant="flat"
data-testid="campaign-delete-confirm"
@click="confirmDelete"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -16,10 +16,14 @@
* без id не работает живой счётчик аудитории (GET .../audience-size требует
* campaign id). Имя черновика — авто, «Реклама от ДД.ММ.ГГГГ» (дата создания).
*
* ⚠️ Брошенные черновики (клиент закрыл мастер, не дойдя до запуска) пока
* НЕ чистятся и НЕ переиспользуются — они остаются в статусе draft. Чистка/
* переиспользование черновиков — следующий инкремент (эндпоинта удаления
* в API B1 нет, выдумывать его здесь нельзя).
* ПЕРЕИСПОЛЬЗОВАНИЕ ЧЕРНОВИКА (T14): без `campaignId` мастер сперва спрашивает
* список кампаний (`fetchCampaigns`) и ищет ПОСЛЕДНИЙ незапущенный черновик
* (`status === 'draft'`, список приходит отсортированным `id` desc — первый
* найденный и есть последний). Нашёлся — продолжаем его через `loadForEdit`
* (без `createDraft`, новый черновик НЕ плодим); это НЕ режим правки —
* `isEditMode` завязан строго на `props.campaignId`, подсказка Р30 не
* показывается. Черновиков нет или список не удалось получить — как раньше,
* `createDraft()`.
*
* РЕЖИМ ПРАВКИ (B2-9, Р30): опциональный prop `campaignId`. Если задан — при
* монтировании грузим существующую кампанию (`fetchCampaign`) и префиллим
@@ -36,8 +40,10 @@ import {
createCampaign,
patchCampaign,
fetchCampaign,
fetchCampaigns,
fetchAudienceSize,
launchCampaign,
uploadCampaignPhones,
type CampaignCreate,
type AudienceSize,
type AdCreative,
@@ -263,15 +269,72 @@ function stepValue(item: unknown): number {
return Number((item as { value: number }).value);
}
/**
* T14 — без `campaignId` не плодим черновик бездумно: сперва смотрим список
* кампаний тенанта и, если там уже есть незапущенный черновик, продолжаем
* его вместо создания нового. Сбой запроса списка — не роняем мастер молча,
* фолбэк на прежнее поведение (`createDraft`).
*/
async function initDraftOrResume(): Promise<void> {
try {
const campaigns = await fetchCampaigns();
const existingDraft = campaigns.find((c) => c.status === 'draft');
if (existingDraft) {
await loadForEdit(existingDraft.id);
return;
}
} catch {
// Список кампаний не удалось получить — не блокируем создание рекламы этим сбоем.
}
await createDraft();
}
onMounted(() => {
if (props.campaignId != null) {
void loadForEdit(props.campaignId);
} else {
void createDraft();
void initDraftOrResume();
}
});
defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError, launchError });
const phonesFile = ref<File | null>(null);
const phonesText = ref('');
const phonesUploading = ref(false);
const phonesUploadError = ref<string | null>(null);
const phonesUploadResult = ref<{ recognized: number; skipped: number } | null>(null);
/** T18 — загрузка «моего списка номеров» с шага 1 (файл и/или текст, хотя бы один нужен серверу). */
async function uploadPhones(): Promise<void> {
if (draftId.value === null) return;
phonesUploadError.value = null;
phonesUploadResult.value = null;
phonesUploading.value = true;
try {
phonesUploadResult.value = await uploadCampaignPhones(draftId.value, {
file: phonesFile.value,
text: phonesText.value,
});
} catch (e) {
phonesUploadError.value = extractErrorMessage(e, 'Не удалось загрузить список номеров.');
} finally {
phonesUploading.value = false;
}
}
defineExpose({
form,
draftId,
audienceSize,
canLaunch,
refreshAudienceSize,
budgetError,
launchError,
phonesFile,
phonesText,
uploadPhones,
phonesUploadResult,
phonesUploadError,
});
</script>
<template>
@@ -349,6 +412,67 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
hide-details
data-testid="use-list-switch"
/>
<div v-if="form.use_uploaded_list" class="mt-3" data-testid="phones-upload-block">
<v-file-input
v-model="phonesFile"
label="Файл со списком номеров"
accept=".csv,.txt"
prepend-icon="mdi-file-upload-outline"
variant="outlined"
density="comfortable"
class="mb-2"
data-testid="phones-file-input"
/>
<v-textarea
v-model="phonesText"
label="или вставьте номера (по одному в строке)"
variant="outlined"
density="comfortable"
rows="3"
auto-grow
class="mb-2"
data-testid="phones-text-input"
/>
<p class="text-caption text-medium-emphasis mb-3">
Загружайте только номера, на использование которых у вас есть согласие их владельцев.
</p>
<v-alert
v-if="phonesUploadResult"
type="success"
variant="tonal"
density="comfortable"
class="mb-3"
data-testid="phones-upload-result"
>
Распознано {{ phonesUploadResult.recognized }}, отброшено {{ phonesUploadResult.skipped }}
</v-alert>
<v-alert
v-if="phonesUploadError"
type="error"
variant="tonal"
density="comfortable"
class="mb-3"
data-testid="phones-upload-error"
>
{{ phonesUploadError }}
</v-alert>
<v-btn
variant="outlined"
color="primary"
prepend-icon="mdi-upload"
:disabled="draftId === null"
:loading="phonesUploading"
data-testid="phones-upload-btn"
@click="uploadPhones"
>
Загрузить список
</v-btn>
</div>
</div>
<div v-else-if="stepValue(itemSlot) === 2" class="pa-4" data-testid="wizard-step-2">
@@ -22,6 +22,8 @@ import {
resumeCampaign,
addCreative,
uploadCreativeImage,
deleteCampaign,
uploadCampaignPhones,
createAdvertisingInvoice,
topupAdvertisingByCard,
} from '../../resources/js/api/advertising';
@@ -193,6 +195,33 @@ describe('api/advertising', () => {
expect(res.hash).toBe('abc123');
});
it('deleteCampaign() ensures csrf then DELETEs /api/advertising/campaigns/{id}', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: null });
await deleteCampaign(12);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.delete).toHaveBeenCalledWith('/api/advertising/campaigns/12');
});
it('uploadCampaignPhones() ensures csrf then POSTs FormData to phones endpoint and returns {recognized,skipped}', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 10, skipped: 2 } });
const file = new File(['79261234567'], 'phones.txt', { type: 'text/plain' });
const res = await uploadCampaignPhones(7, { file, text: '79261111111' });
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/phones', expect.any(FormData));
const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
expect(sentForm.get('file')).toBe(file);
expect(sentForm.get('text')).toBe('79261111111');
expect(res).toEqual({ recognized: 10, skipped: 2 });
});
it('uploadCampaignPhones() omits file/text from FormData when not given', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 3, skipped: 0 } });
await uploadCampaignPhones(7, {});
const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData;
expect(sentForm.get('file')).toBeNull();
expect(sentForm.get('text')).toBeNull();
});
it('createAdvertisingInvoice() ensures csrf then POSTs /api/billing/invoices с credit_target=advertising', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
@@ -2,24 +2,39 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
const { fetchCampaignsMock, pauseCampaignMock, resumeCampaignMock, fetchCampaignMock } = vi.hoisted(() => ({
fetchCampaignsMock: vi.fn(),
pauseCampaignMock: vi.fn(),
resumeCampaignMock: vi.fn(),
fetchCampaignMock: vi.fn(),
}));
const { fetchCampaignsMock, pauseCampaignMock, resumeCampaignMock, fetchCampaignMock, deleteCampaignMock } =
vi.hoisted(() => ({
fetchCampaignsMock: vi.fn(),
pauseCampaignMock: vi.fn(),
resumeCampaignMock: vi.fn(),
fetchCampaignMock: vi.fn(),
deleteCampaignMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
fetchCampaigns: fetchCampaignsMock,
pauseCampaign: pauseCampaignMock,
resumeCampaign: resumeCampaignMock,
fetchCampaign: fetchCampaignMock,
deleteCampaign: deleteCampaignMock,
}));
import CampaignList from '../../resources/js/components/advertising/CampaignList.vue';
const vuetify = createVuetify();
/**
* VDialog в jsdom (без реальных transitions/teleport target) не рендерит слот
* надёжно через `mount` — тот же приём, что и в NewDealDialog.spec.ts: стабим
* компонент собственным условным рендером по `modelValue`.
*/
const DIALOG_STUBS = {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
};
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
}
@@ -30,6 +45,7 @@ describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)
pauseCampaignMock.mockReset();
resumeCampaignMock.mockReset();
fetchCampaignMock.mockReset();
deleteCampaignMock.mockReset();
fetchCampaignMock.mockResolvedValue({
campaign: {
id: 1,
@@ -192,6 +208,109 @@ describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)
expect(w.find('[data-testid="campaign-resume-btn"]').exists()).toBe(false);
});
it('T16 — имя карточки показывает id кампании (различить одинаково названные черновики)', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 21,
name: 'Реклама от 25.07.2026',
status: 'draft',
weekly_budget_rub: '1000.00',
audience_days: 7,
launched_at: null,
},
]);
const w = mount(CampaignList, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(w.find('[data-testid="campaign-name"]').text()).toBe('Реклама от 25.07.2026 #21');
});
it('T15 — у draft-кампании есть кнопка «Удалить»; у не-draft — нет', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 30,
name: 'Черновик',
status: 'draft',
weekly_budget_rub: '500.00',
audience_days: 7,
launched_at: null,
},
{
id: 31,
name: 'Крутится',
status: 'running',
weekly_budget_rub: '2000.00',
audience_days: 10,
launched_at: '2026-07-20T10:00:00Z',
},
]);
const w = mount(CampaignList, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const deleteBtns = w.findAll('[data-testid="campaign-delete-btn"]');
expect(deleteBtns).toHaveLength(1);
});
it('T15 — клик «Удалить» просит подтверждение; после подтверждения зовёт deleteCampaign и перезагружает список', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 40,
name: 'На удаление',
status: 'draft',
weekly_budget_rub: '500.00',
audience_days: 7,
launched_at: null,
},
]);
deleteCampaignMock.mockResolvedValue(undefined);
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
await flush();
await w.vm.$nextTick();
await w.find('[data-testid="campaign-delete-btn"]').trigger('click');
await w.vm.$nextTick();
// Подтверждение — deleteCampaign ещё НЕ вызван просто по клику на «Удалить».
expect(deleteCampaignMock).not.toHaveBeenCalled();
await w.find('[data-testid="campaign-delete-confirm"]').trigger('click');
await flush();
await w.vm.$nextTick();
expect(deleteCampaignMock).toHaveBeenCalledWith(40);
expect(fetchCampaignsMock).toHaveBeenCalledTimes(2);
});
it('T15 — «Отмена» в диалоге подтверждения не зовёт deleteCampaign', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 41,
name: 'Передумали',
status: 'draft',
weekly_budget_rub: '500.00',
audience_days: 7,
launched_at: null,
},
]);
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
await flush();
await w.vm.$nextTick();
await w.find('[data-testid="campaign-delete-btn"]').trigger('click');
await w.vm.$nextTick();
await w.find('[data-testid="campaign-delete-cancel"]').trigger('click');
await w.vm.$nextTick();
expect(deleteCampaignMock).not.toHaveBeenCalled();
});
it('клик «Отчёт» открывает диалог отчёта кампании', async () => {
fetchCampaignsMock.mockResolvedValue([
{
@@ -7,20 +7,26 @@ const {
fetchAudienceSizeMock,
patchCampaignMock,
fetchCampaignMock,
fetchCampaignsMock,
addCreativeMock,
uploadCreativeImageMock,
launchCampaignMock,
uploadCampaignPhonesMock,
} = vi.hoisted(() => ({
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
patchCampaignMock: vi.fn(),
// B2-9 — правка на ходу: fetchCampaign(id) грузит существующую кампанию.
fetchCampaignMock: vi.fn(),
// T14 — переиспользование черновика: без campaignId мастер сперва смотрит список.
fetchCampaignsMock: vi.fn(),
// Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
// который смонтирован внутри мастера.
addCreativeMock: vi.fn(),
uploadCreativeImageMock: vi.fn(),
launchCampaignMock: vi.fn(),
// T18 — загрузка «моего списка номеров» с шага 1.
uploadCampaignPhonesMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
@@ -28,9 +34,11 @@ vi.mock('../../resources/js/api/advertising', () => ({
fetchAudienceSize: fetchAudienceSizeMock,
patchCampaign: patchCampaignMock,
fetchCampaign: fetchCampaignMock,
fetchCampaigns: fetchCampaignsMock,
addCreative: addCreativeMock,
uploadCreativeImage: uploadCreativeImageMock,
launchCampaign: launchCampaignMock,
uploadCampaignPhones: uploadCampaignPhonesMock,
}));
/**
@@ -66,6 +74,9 @@ describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () =
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
// T14 — по умолчанию черновиков нет, мастер идёт прежним путём (createDraft).
fetchCampaignsMock.mockResolvedValue([]);
});
it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => {
@@ -154,6 +165,8 @@ describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
launchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
fetchCampaignsMock.mockResolvedValue([]);
});
/** Переходим на шаг 3 кликами «Далее» — так же, как реальный пользователь. */
@@ -272,6 +285,8 @@ describe('CampaignWizard.vue (B2-8 — шаг 4 «Проверка и запус
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
launchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
fetchCampaignsMock.mockResolvedValue([]);
});
/** Доходим до шага 4 так же, как реальный пользователь — тремя кликами «Далее». */
@@ -392,6 +407,8 @@ describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () =>
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
fetchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
fetchCampaignsMock.mockResolvedValue([]);
});
const EXISTING_CAMPAIGN = {
@@ -441,7 +458,7 @@ describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () =>
expect(Number((weeklyInput.element as HTMLInputElement).value)).toBe(3500);
});
it('(b) без campaignId — прежнее поведение: createCampaign зовётся, wizard-edit-hint не виден', async () => {
it('(b) без campaignId и без черновиков в списке — createCampaign зовётся, wizard-edit-hint не виден (T14)', async () => {
createCampaignMock.mockResolvedValue({
id: 99,
name: 'Реклама от 25.07.2026',
@@ -451,14 +468,153 @@ describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () =>
launched_at: null,
});
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
// beforeEach уже даёт fetchCampaignsMock -> [] (черновиков нет).
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(fetchCampaignsMock).toHaveBeenCalledTimes(1);
expect(createCampaignMock).toHaveBeenCalledTimes(1);
expect(fetchCampaignMock).not.toHaveBeenCalled();
expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false);
expect(w.text()).not.toContain('Изменить кампанию');
});
});
describe('CampaignWizard.vue (T14 — переиспользование черновика вместо плодения новых)', () => {
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
fetchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
});
const EXISTING_DRAFT = {
id: 77,
name: 'Реклама от 24.07.2026',
status: 'draft' as const,
weekly_budget_rub: '800.00',
audience_days: 5,
launched_at: null,
};
it('без campaignId, но с черновиком в списке — продолжает его (loadForEdit), createCampaign НЕ зовётся, isEditMode/подсказка НЕ включаются', async () => {
fetchCampaignsMock.mockResolvedValue([
{ id: 78, name: 'Крутится', status: 'running' as const, weekly_budget_rub: '2000.00', audience_days: 10, launched_at: '2026-07-01T00:00:00Z' },
EXISTING_DRAFT,
]);
fetchCampaignMock.mockResolvedValue({ campaign: EXISTING_DRAFT, ads: [], spent_rub: '0.00' });
fetchAudienceSizeMock.mockResolvedValue({ size: 50, min: 100, enough: false, hint: 'мало' });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(fetchCampaignsMock).toHaveBeenCalledTimes(1);
expect(fetchCampaignMock).toHaveBeenCalledWith(77);
expect(createCampaignMock).not.toHaveBeenCalled();
expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(77);
// Продолжение черновика — это НЕ режим правки (тот завязан на props.campaignId).
expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false);
expect(w.text()).toContain('Новая реклама');
expect(w.text()).not.toContain('Изменить кампанию');
});
it('список кампаний упал (сбой сети) — не роняем мастер, фолбэк на createDraft', async () => {
fetchCampaignsMock.mockRejectedValue(new Error('network'));
createCampaignMock.mockResolvedValue({
id: 88,
name: 'Реклама от 25.07.2026',
status: 'draft' as const,
weekly_budget_rub: '1000.00',
audience_days: 10,
launched_at: null,
});
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(createCampaignMock).toHaveBeenCalledTimes(1);
expect(fetchCampaignMock).not.toHaveBeenCalled();
expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false);
expect(w.text()).not.toContain('Изменить кампанию');
expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(88);
expect(w.find('[data-testid="wizard-draft-error"]').exists()).toBe(false);
});
});
describe('CampaignWizard.vue (T18 — загрузка «моего списка номеров» на шаге 1)', () => {
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
fetchCampaignsMock.mockReset();
fetchCampaignsMock.mockResolvedValue([]);
uploadCampaignPhonesMock.mockReset();
createCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN });
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
});
it('блок загрузки скрыт, пока переключатель «мой список» выключен; появляется после включения', async () => {
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(w.find('[data-testid="phones-upload-block"]').exists()).toBe(false);
const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input');
await uploadSwitchInput.setValue(true);
await w.vm.$nextTick();
expect(w.find('[data-testid="phones-upload-block"]').exists()).toBe(true);
});
it('кнопка «Загрузить список» зовёт uploadCampaignPhones(draftId, {file,text}) и показывает результат', async () => {
uploadCampaignPhonesMock.mockResolvedValue({ recognized: 8, skipped: 1 });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input');
await uploadSwitchInput.setValue(true);
await w.vm.$nextTick();
const file = new File(['79261234567'], 'phones.txt', { type: 'text/plain' });
(w.vm as unknown as { phonesFile: File | null }).phonesFile = file;
(w.vm as unknown as { phonesText: string }).phonesText = '79267654321';
await w.vm.$nextTick();
await w.find('[data-testid="phones-upload-btn"]').trigger('click');
await flush();
await w.vm.$nextTick();
expect(uploadCampaignPhonesMock).toHaveBeenCalledWith(99, { file, text: '79267654321' });
const result = w.find('[data-testid="phones-upload-result"]');
expect(result.exists()).toBe(true);
expect(result.text()).toContain('Распознано 8');
expect(result.text()).toContain('отброшено 1');
});
it('ошибка загрузки списка показывает понятный текст', async () => {
uploadCampaignPhonesMock.mockRejectedValue(makeAxiosError('Не удалось разобрать файл.', 422));
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input');
await uploadSwitchInput.setValue(true);
await w.vm.$nextTick();
await w.find('[data-testid="phones-upload-btn"]').trigger('click');
await flush();
await w.vm.$nextTick();
const error = w.find('[data-testid="phones-upload-error"]');
expect(error.exists()).toBe(true);
expect(error.text()).toContain('Не удалось разобрать файл.');
});
});