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:
@@ -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('Не удалось разобрать файл.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user