fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной. 1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету: чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера это потолок, до которого кампания почти наверняка не дотянет. Стало «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги; за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты и в сводке перед отправкой. 2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное. Списание делает часовая задача, а она берёт только кампании со статусом «крутится» — остановленную пропускала навсегда. Показы последнего часа уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай; не узнал число показов — не отпускай вовсе. 3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов. Вынесено наружу: денежная операция закрывается, и только потом остановка. Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб за каждый календарный день и отвечает по-английски на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал: ею пользуются и запуск, и мастер — две копии однажды разошлись бы. Сторожа: 7 новых на бэкенде и фронте, каждый принят красным. Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
This commit is contained in:
@@ -26,6 +26,7 @@ use App\Services\Advertising\CreativeJobService;
|
||||
use App\Services\Advertising\CreativeValidator;
|
||||
use App\Services\Advertising\ModerationReason;
|
||||
use App\Services\Advertising\YandexDirectClient;
|
||||
use App\Services\Advertising\YandexMinimumSpend;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -409,6 +410,14 @@ class AdvertisingCampaignController extends Controller
|
||||
$payload['impressions'] = $est['impressions'];
|
||||
$payload['cpm_rub'] = $est['cpm_rub'];
|
||||
$payload['cost_rub'] = $est['cost_rub'];
|
||||
|
||||
// Минимум площадки — сюда же, чтобы клиент узнал о нём В МАСТЕРЕ, а не
|
||||
// английским отказом Директа после пятнадцати часов сбора аудитории.
|
||||
// Считает сервер: в расчёте участвует наша наценка, и наружу из неё не уходит
|
||||
// ничего, кроме показов и клиентских рублей.
|
||||
$minimum = app(YandexMinimumSpend::class)->minimum((int) ($campaign->run_days ?: 30), $cpm);
|
||||
$payload['min_impressions'] = $minimum['impressions'];
|
||||
$payload['min_cost_rub'] = $minimum['cost_rub'];
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
|
||||
@@ -73,12 +73,25 @@ class ChargeCampaignSpendJob implements ShouldQueue
|
||||
->whereNotNull('yandex_campaign_id')
|
||||
->get(['id', 'tenant_id', 'yandex_campaign_id']);
|
||||
|
||||
// Кого останавливать — копим здесь и глушим ПОСЛЕ денежных транзакций.
|
||||
// 🔴 Раньше stopAll() звался прямо внутри транзакции, а он ходит в Яндекс: глушит
|
||||
// каждую кампанию тенанта и спрашивает у неё число показов, чтобы заплатить за них
|
||||
// перед возвратом заморозки. Замок строки кампании висел всё это время — по два
|
||||
// сетевых запроса на каждую кампанию. Один медленный ответ Директа запирал деньги
|
||||
// клиента на минуты.
|
||||
//
|
||||
// Цена выноса: если процесс умрёт между списанием и остановкой, реклама доживёт до
|
||||
// следующего часового обхода — он снова увидит нехватку денег и заглушит. Это
|
||||
// несравнимо дешевле, чем держать денежный замок на сетевом запросе.
|
||||
$ostanovit = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id);
|
||||
$tenantId = (int) $row->tenant_id;
|
||||
$neplatyozhesposoben = false;
|
||||
|
||||
DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void {
|
||||
DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, &$neplatyozhesposoben): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Замок строки обязателен. Идемпотентность списания держится на ключе
|
||||
@@ -91,10 +104,12 @@ class ChargeCampaignSpendJob implements ShouldQueue
|
||||
$campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail();
|
||||
$charger->charge($campaign, $delivered);
|
||||
|
||||
if (! $gate->isSolvent($tenantId)) {
|
||||
$stopAll->stopAll($tenantId);
|
||||
}
|
||||
$neplatyozhesposoben = ! $gate->isSolvent($tenantId);
|
||||
});
|
||||
|
||||
if ($neplatyozhesposoben) {
|
||||
$ostanovit[$tenantId] = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [
|
||||
'campaign_id' => $row->id,
|
||||
@@ -103,6 +118,22 @@ class ChargeCampaignSpendJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($ostanovit) as $tenantId) {
|
||||
try {
|
||||
// Контекст тенанта нужен и здесь: на проде очередь бежит под crm_app_user,
|
||||
// и без него RLS показал бы слушателю НОЛЬ кампаний — реклама осталась бы
|
||||
// крутиться при пустом кошельке, молча. Ставим сессионно (третий аргумент
|
||||
// false), а не SET LOCAL: своей транзакции тут намеренно нет.
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenantId]);
|
||||
$stopAll->stopAll($tenantId);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('ChargeCampaignSpendJob: сбой остановки рекламы тенанта', [
|
||||
'tenant_id' => $tenantId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
|
||||
|
||||
@@ -7,13 +7,17 @@ namespace App\Listeners;
|
||||
use App\Events\AdvertisingStopped;
|
||||
use App\Models\AdCampaign;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use App\Services\Advertising\CampaignImpressionCharger;
|
||||
use App\Services\Advertising\YandexDirectClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
final class PauseCampaignsOnAdStop
|
||||
{
|
||||
public function __construct(private readonly AdWalletService $wallet) {}
|
||||
public function __construct(
|
||||
private readonly AdWalletService $wallet,
|
||||
private readonly CampaignImpressionCharger $charger,
|
||||
) {}
|
||||
|
||||
public function handle(AdvertisingStopped $event): void
|
||||
{
|
||||
@@ -40,8 +44,43 @@ final class PauseCampaignsOnAdStop
|
||||
Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Сперва платим за уже показанное, и только потом отпускаем деньги.
|
||||
//
|
||||
// Списание за показы делает часовая задача, и она берёт ТОЛЬКО кампании со
|
||||
// статусом «крутится». Остановленную она пропускает НАВСЕГДА: до часа показов
|
||||
// уходило клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
|
||||
// в паузе (§6и отчёта приёмки 04.08.2026), только через другую дверь.
|
||||
//
|
||||
// Лишнего обращения в Директ это не стоит: мы и так только что ходили туда
|
||||
// глушить рекламу.
|
||||
$uznaliPokazy = true;
|
||||
if ($direct !== null && $campaign->yandex_campaign_id !== null) {
|
||||
try {
|
||||
$this->charger->charge($campaign, $direct->getCampaignImpressions((int) $campaign->yandex_campaign_id));
|
||||
} catch (Throwable $e) {
|
||||
$uznaliPokazy = false;
|
||||
Log::warning('PauseCampaignsOnAdStop charge failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
|
||||
}
|
||||
}
|
||||
|
||||
$campaign->refresh();
|
||||
|
||||
// Списание могло само закрыть кампанию — смета откручена. Тогда она уже в
|
||||
// «Показы откручены», остаток заморозки вернулся (ВЫХОД 1), и статуса
|
||||
// «нет денег» поверх не ставим: пятого выхода снятия заморозки не появляется.
|
||||
if ($campaign->status === AdCampaign::STATUS_COMPLETED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]);
|
||||
|
||||
// Не узнали число показов — рекламу заглушили, но деньги НЕ отпускаем:
|
||||
// отпустить их, не заплатив за показы, значит подарить их.
|
||||
if (! $uznaliPokazy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на
|
||||
// мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту
|
||||
// распорядиться пополнением (в т.ч. перезапустить рекламу).
|
||||
|
||||
@@ -36,6 +36,7 @@ final class CampaignLauncher
|
||||
private readonly CampaignAudienceBuilder $audience,
|
||||
private readonly AdWalletService $wallet,
|
||||
private readonly AdImpressionPricing $pricing,
|
||||
private readonly YandexMinimumSpend $minimum,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -263,22 +264,15 @@ final class CampaignLauncher
|
||||
//
|
||||
// Проверяем ЗДЕСЬ — до первого обращения к Яндексу: в кабинете ничего не создаётся,
|
||||
// деньги не морозятся, кампания остаётся черновиком.
|
||||
$dneyVPeriode = CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1;
|
||||
$minSpendRub = bcmul(
|
||||
(string) config('services.yandex_direct.min_spend_limit_rub_per_day'),
|
||||
(string) $dneyVPeriode,
|
||||
2,
|
||||
);
|
||||
//
|
||||
// 🔑 Счёт минимума живёт в YandexMinimumSpend и ОДИН на портал: этим же счётчиком
|
||||
// мастер предупреждает клиента заранее. Две копии одного расчёта однажды разойдутся,
|
||||
// и клиент увидит в мастере одно число, а в отказе Директа — другое.
|
||||
$dneyVPeriode = (int) CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1;
|
||||
$minimum = $this->minimum->minimumForDays($dneyVPeriode, $clientCpm);
|
||||
$spendLimitRub = bcdiv((string) $spendLimitMicros, '1000000', 2);
|
||||
if (bccomp($spendLimitRub, $minSpendRub, 2) < 0) {
|
||||
// Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума.
|
||||
$rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8);
|
||||
$minImpressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6));
|
||||
|
||||
throw new BudgetBelowYandexMinimumException(
|
||||
$minImpressions,
|
||||
$this->pricing->clientCostRub($minImpressions, $clientCpm),
|
||||
);
|
||||
if (bccomp($spendLimitRub, $minimum['spend_rub'], 2) < 0) {
|
||||
throw new BudgetBelowYandexMinimumException($minimum['impressions'], $minimum['cost_rub']);
|
||||
}
|
||||
|
||||
$freqImpr = (int) ($campaign->frequency ?? 1);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Advertising;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Минимум площадки: сколько показов нужно заказать, чтобы Директ вообще взял кампанию.
|
||||
*
|
||||
* 🔴 Живой отказ 30.07.2026: «Budget for this period cannot be less than 600 rub.» —
|
||||
* по-английски, на последнем шаге, после пятнадцати часов сбора аудитории и пятнадцати
|
||||
* загруженных картинок. Минимум НЕ постоянный: он считается от длины периода — 300 ₽ за
|
||||
* каждый календарный день, считая оба края. Первый отказ приняли за постоянные 600 ₽ и
|
||||
* тут же напоролись на второй: та же кампания на неделю потребовала 2400 ₽.
|
||||
*
|
||||
* Счётчик ОДИН на портал — им пользуются и запуск (чтобы не лезть в Яндекс заведомо
|
||||
* зря), и мастер (чтобы предупредить клиента заранее). Две копии одного расчёта однажды
|
||||
* разойдутся, и верить будет нечему.
|
||||
*
|
||||
* Считать это в браузере нельзя: в расчёте участвует наша наценка, а клиенту её видеть
|
||||
* не положено. Поэтому наружу уходят только показы и клиентские рубли.
|
||||
*
|
||||
* MONEY: только bcmath.
|
||||
*/
|
||||
final class YandexMinimumSpend
|
||||
{
|
||||
public function __construct(private readonly AdImpressionPricing $pricing) {}
|
||||
|
||||
/**
|
||||
* Минимум для кампании, которая будет крутиться `runDays` дней.
|
||||
*
|
||||
* @return array{impressions:int, cost_rub:string, days:int}
|
||||
*/
|
||||
public function minimum(int $runDays, string $clientCpmRub): array
|
||||
{
|
||||
return $this->minimumForDays($this->dneyVPeriode($runDays), $clientCpmRub);
|
||||
}
|
||||
|
||||
/**
|
||||
* То же, но когда длина периода уже известна точно (запуск считает её по датам,
|
||||
* которые уходят в Директ, и переиспользует сохранённую дату конца).
|
||||
*
|
||||
* @return array{impressions:int, cost_rub:string, days:int, spend_rub:string}
|
||||
*/
|
||||
public function minimumForDays(int $dney, string $clientCpmRub): array
|
||||
{
|
||||
$dney = max(1, $dney);
|
||||
|
||||
$margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00');
|
||||
$share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ
|
||||
$yandexCpm = bcmul($clientCpmRub, $share, 2); // ₽ за 1000 для Яндекса
|
||||
$guard = (string) config('services.yandex_direct.spend_limit_guard_multiplier');
|
||||
|
||||
$minSpendRub = bcmul(
|
||||
(string) config('services.yandex_direct.min_spend_limit_rub_per_day'),
|
||||
(string) $dney,
|
||||
2,
|
||||
);
|
||||
|
||||
// Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума.
|
||||
$rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8);
|
||||
$impressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6));
|
||||
|
||||
return [
|
||||
'impressions' => $impressions,
|
||||
'cost_rub' => $this->pricing->clientCostRub($impressions, $clientCpmRub),
|
||||
'days' => $dney,
|
||||
'spend_rub' => $minSpendRub,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Длина периода в календарных днях — ровно так, как её считает запуск: от старта до
|
||||
* конца ВКЛЮЧИТЕЛЬНО. Срок в один день означает период «сегодня…завтра», то есть два
|
||||
* дня по календарю Яндекса, а значит и минимум двойной.
|
||||
*/
|
||||
public function dneyVPeriode(int $runDays): int
|
||||
{
|
||||
$start = CarbonImmutable::now()->startOfDay();
|
||||
$end = $start->addDays(max($runDays - 1, 1));
|
||||
|
||||
return (int) $start->diffInDays($end) + 1;
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@ export interface AudienceSize {
|
||||
impressions?: number;
|
||||
cpm_rub?: string;
|
||||
cost_rub?: string;
|
||||
/**
|
||||
* Минимум площадки: Директ не берёт кампанию, у которой расход за период ниже его
|
||||
* минимума (300 ₽ за каждый календарный день). Считает сервер — в расчёте участвует
|
||||
* наша наценка, и клиенту её видеть не положено; сюда приходят только показы и
|
||||
* клиентские рубли. Живой отказ 30.07.2026 клиент видел по-английски и на последнем шаге.
|
||||
*/
|
||||
min_impressions?: number;
|
||||
min_cost_rub?: string;
|
||||
}
|
||||
|
||||
/** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */
|
||||
|
||||
@@ -98,6 +98,22 @@ const canSubmit = computed(() => audienceSize.value?.enough ?? false);
|
||||
/** Клиентская цена за 1000 показов — берём из ответа сервера (cpm_rub), НЕ хардкодим. */
|
||||
const cpmLabel = computed(() => `${formatPlain(Number(audienceSize.value?.cpm_rub ?? 120))} за 1000 показов`);
|
||||
|
||||
/**
|
||||
* Смета не дотягивает до нижнего порога площадки.
|
||||
*
|
||||
* 🔴 Директ отказывает по-английски и на последнем шаге — после того как клиент собрал
|
||||
* аудиторию (пятнадцать часов) и залил пятнадцать картинок. Порог считает сервер (в
|
||||
* расчёте наша наценка), сюда приходят только показы и клиентские рубли.
|
||||
*/
|
||||
const nizheMinimuma = computed(() => {
|
||||
const est = audienceSize.value;
|
||||
if (audienceLoading.value || !est || est.impressions == null || est.min_impressions == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return est.impressions < est.min_impressions;
|
||||
});
|
||||
|
||||
/** Человекочитаемая подпись режима сбора аудитории — для сводки шага 4. */
|
||||
const modeLabel = computed(() => (form.mode === 'auto' ? 'Крутить постоянно' : 'Разовый запуск'));
|
||||
|
||||
@@ -854,9 +870,16 @@ defineExpose({
|
||||
<v-list density="comfortable" class="mb-2 border rounded" data-testid="frequency-estimate">
|
||||
<v-list-item prepend-icon="mdi-eye-outline">
|
||||
<v-list-item-title>Показов</v-list-item-title>
|
||||
<v-list-item-subtitle class="num">
|
||||
<!--
|
||||
🔴 Это ПОТОЛОК, а не обещание. Живой замер 04.08.2026: чужая
|
||||
медийная кампания с бюджетом 13 000 ₽ за четверо суток набрала
|
||||
457 показов и потратила 138 ₽ — деньги не кончились, кончились
|
||||
люди. Пока здесь стояло «~8150», клиент читал это как «столько
|
||||
будет» и узнавал правду через сутки после заморозки денег.
|
||||
-->
|
||||
<v-list-item-subtitle class="num" data-testid="pokazy-potolok">
|
||||
<v-progress-circular v-if="audienceLoading" indeterminate size="16" width="2" color="primary" />
|
||||
<template v-else>~{{ audienceSize?.impressions ?? 0 }}</template>
|
||||
<template v-else>не больше {{ audienceSize?.impressions ?? 0 }}</template>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-tag-outline">
|
||||
@@ -871,6 +894,31 @@ defineExpose({
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<p
|
||||
v-if="audienceSize && !audienceLoading"
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
data-testid="pokazy-potolok-poyasnenie"
|
||||
>
|
||||
Это потолок, а не обещание. Реклама упрётся не в деньги, а в ваш
|
||||
список: Яндекс покажет её ровно тем людям, кого сумеет узнать в
|
||||
интернете, и только когда они туда зайдут. За показы, которых не
|
||||
случится, деньги вернутся на счёт.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="nizheMinimuma"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
class="mb-3"
|
||||
data-testid="minimum-ploshchadki"
|
||||
>
|
||||
Столько показов Яндекс не возьмёт: у него есть нижний порог на такой
|
||||
срок. Самая маленькая кампания здесь — {{ audienceSize?.min_impressions }}
|
||||
показов за {{ formatPlain(Number(audienceSize?.min_cost_rub ?? 0)) }}.
|
||||
Поднимите частоту показов или укоротите срок.
|
||||
</v-alert>
|
||||
|
||||
<p v-if="frequencyError" class="text-body-2 text-error" data-testid="wizard-frequency-error">
|
||||
{{ frequencyError }}
|
||||
</p>
|
||||
@@ -1047,7 +1095,10 @@ defineExpose({
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-eye-outline">
|
||||
<v-list-item-title>Показов</v-list-item-title>
|
||||
<v-list-item-subtitle class="num">~{{ audienceSize?.impressions ?? 0 }}</v-list-item-subtitle>
|
||||
<!-- Тот же потолок, что и на шаге 2 — сводка не должна обещать больше. -->
|
||||
<v-list-item-subtitle class="num" data-testid="svodka-pokazy">
|
||||
не больше {{ audienceSize?.impressions ?? 0 }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-tag-outline">
|
||||
<v-list-item-title>Цена</v-list-item-title>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Events\AdvertisingStopped;
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\AdWallet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Advertising\AdWalletService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты.
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* ВЫХОД 3 — «Остановлено, нет денег».
|
||||
*
|
||||
* 🔴 Дыра, найденная 04.08.2026 рядом с той же дырой в паузе (§6и отчёта приёмки).
|
||||
* Когда у клиента кончались деньги, портал глушил рекламу в Яндексе, ставил статус
|
||||
* «Остановлено, нет денег» и БЕЗУСЛОВНО возвращал заморозку — не заплатив за показы,
|
||||
* которые уже случились. Списание за показы делает часовая задача, и она берёт ТОЛЬКО
|
||||
* кампании со статусом «крутится»: остановленную она пропускает навсегда.
|
||||
*
|
||||
* Итог: показы последнего часа уходили клиенту даром, а Яндексу за них платили мы.
|
||||
* Ровно то же, что чинили в паузе, только через другую дверь.
|
||||
*
|
||||
* Правило то же: СПЕРВА заплати за показанное, ПОТОМ отпускай деньги. Не смог узнать
|
||||
* число показов — деньги не отпускай вовсе: отпустить, не заплатив, значит подарить.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
config(['services.yandex_direct.enabled' => true]);
|
||||
config(['services.yandex_direct.token' => 'DIRTOKEN']);
|
||||
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
|
||||
});
|
||||
|
||||
it('остановка без денег сперва платит за показанное, потом отпускает заморозку', function () {
|
||||
Http::fake([
|
||||
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 777]]]]),
|
||||
'*/json/v5/reports' => Http::response('2500', 200), // накопительно за всё время
|
||||
]);
|
||||
|
||||
$svc = app(AdWalletService::class);
|
||||
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
|
||||
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Кончились деньги на ходу',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'yandex_campaign_id' => 777,
|
||||
'estimated_impressions' => 10000,
|
||||
'paid_impressions' => 10000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
|
||||
|
||||
event(new AdvertisingStopped($this->tenant->id));
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
|
||||
// 2500 показов × 120 ₽ за тысячу = 300 ₽ — они обязаны уйти с баланса.
|
||||
expect((string) $campaign->fresh()->charged_client_rub)->toBe('300.00')
|
||||
->and((string) $wallet->balance_rub)->toBe('2700.00')
|
||||
->and((string) $wallet->frozen_rub)->toBe('0.00')
|
||||
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
|
||||
});
|
||||
|
||||
it('не узнали число показов — рекламу глушим, но деньги НЕ отпускаем', function () {
|
||||
Http::fake([
|
||||
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 778]]]]),
|
||||
'*/json/v5/reports' => Http::response(['error' => ['error_detail' => 'нет доступа']], 400),
|
||||
]);
|
||||
|
||||
$svc = app(AdWalletService::class);
|
||||
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
|
||||
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Отчёт не отдался',
|
||||
'audience_days' => 10,
|
||||
'status' => AdCampaign::STATUS_RUNNING,
|
||||
'yandex_campaign_id' => 778,
|
||||
'estimated_impressions' => 10000,
|
||||
'paid_impressions' => 10000,
|
||||
'charged_client_rub' => '0.00',
|
||||
]);
|
||||
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
|
||||
|
||||
event(new AdvertisingStopped($this->tenant->id));
|
||||
|
||||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||||
|
||||
// Реклама заглушена — статус сменился.
|
||||
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS)
|
||||
// Но заморозка на месте: отпустить, не заплатив, значит подарить показы.
|
||||
->and((string) $wallet->frozen_rub)->toBe('1200.00')
|
||||
->and((string) $campaign->fresh()->charged_client_rub)->toBe('0.00');
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AdCampaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Минимум площадки — в мастере, а не на последнем шаге.
|
||||
*
|
||||
* 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию, у которой расход за период ниже
|
||||
* его минимума, и отвечает по-английски — «Budget for this period cannot be less than
|
||||
* 600 rub.». Клиент видел этот текст ПОСЛЕ того, как собрал аудиторию (15 часов),
|
||||
* залил пятнадцать картинок и нажал «Запустить». Понять из него, чего от него хотят,
|
||||
* невозможно.
|
||||
*
|
||||
* Минимум считается от длины периода — 300 ₽ за каждый календарный день. Перевести его
|
||||
* в понятные клиенту величины (сколько показов и сколько рублей) можно только на сервере:
|
||||
* в расчёте участвует наша наценка, а её клиенту показывать нельзя.
|
||||
*
|
||||
* 🔑 Формула ОДНА на портал: и запуск, и мастер спрашивают один и тот же счётчик.
|
||||
* Две копии одного расчёта однажды разойдутся, и верить будет нечему.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
config(['services.yandex_direct.min_spend_limit_rub_per_day' => 300]);
|
||||
config(['services.yandex_direct.spend_limit_guard_multiplier' => '1.2']);
|
||||
DB::table('ad_settings')->update(['ad_margin_percent' => '40.00']);
|
||||
});
|
||||
|
||||
it('мастер получает минимум площадки в показах и рублях, наценку не раскрывая', function () {
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Черновик',
|
||||
'status' => AdCampaign::STATUS_DRAFT,
|
||||
'audience_days' => 10,
|
||||
'mode' => AdCampaign::MODE_AUTO,
|
||||
'run_days' => 1,
|
||||
'client_cpm_rub' => '500.00',
|
||||
]);
|
||||
|
||||
$otvet = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?frequency=5&days=10")
|
||||
->assertOk();
|
||||
|
||||
// Срок 1 день → период сегодня…завтра = 2 календарных дня → минимум Яндексу 600 ₽.
|
||||
// Яндексу уходит 60 % от 500 ₽ = 300 ₽ за тысячу; с запасом 1,2 это 0,36 ₽ за показ.
|
||||
// 600 / 0,36 = 1 666,67 → 1 667 показов. Клиенту это 1667 × 500 / 1000 = 833,50 ₽.
|
||||
$otvet->assertJsonPath('min_impressions', 1667)
|
||||
->assertJsonPath('min_cost_rub', '833.50');
|
||||
|
||||
// Наценка, доля Яндекса и его ставка клиенту не видны ни в каком виде.
|
||||
$telo = $otvet->getContent();
|
||||
expect($telo)->not->toContain('margin')
|
||||
->and($telo)->not->toContain('yandex_cpm')
|
||||
->and($telo)->not->toContain('300.00');
|
||||
});
|
||||
|
||||
it('запуск и мастер считают минимум ОДНИМ счётчиком', function () {
|
||||
$schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class);
|
||||
|
||||
$itog = $schetchik->minimum(1, '500.00');
|
||||
|
||||
expect($itog['impressions'])->toBe(1667)
|
||||
->and($itog['cost_rub'])->toBe('833.50');
|
||||
});
|
||||
|
||||
it('длиннее срок — выше минимум: неделя стоит семь дней по 300 ₽', function () {
|
||||
$schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class);
|
||||
|
||||
// Срок 7 дней → период сегодня…+6 = 7 календарных дней → 2100 ₽ Яндексу.
|
||||
// 2100 / 0,36 = 5 833,33 → 5 834 показа → 2 917,00 ₽ клиенту.
|
||||
$itog = $schetchik->minimum(7, '500.00');
|
||||
|
||||
expect($itog['impressions'])->toBe(5834)
|
||||
->and($itog['cost_rub'])->toBe('2917.00');
|
||||
});
|
||||
@@ -244,6 +244,11 @@ it('ВЫХОД 3: остановка без средств снимает зам
|
||||
]);
|
||||
Http::fake([
|
||||
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
|
||||
// 🔴 С 04.08.2026 остановка сперва платит за уже показанное и только потом
|
||||
// отпускает заморозку (AdStopNoFundsChargesFirstTest). Значит миру теста нужен
|
||||
// ответ Директа про показы: без него портал НАМЕРЕННО не отпустит деньги —
|
||||
// отпустить, не заплатив, значит подарить показы. Ноль показов = платить не за что.
|
||||
'*/json/v5/reports' => Http::response('0', 200),
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -145,6 +145,42 @@ it('stops all advertising when the charge leaves the tenant insolvent', function
|
||||
Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id);
|
||||
});
|
||||
|
||||
/**
|
||||
* 🔴 Остановка «нет денег» ходит в Яндекс: глушит каждую кампанию и спрашивает у неё
|
||||
* число показов, чтобы заплатить за них перед возвратом заморозки. Пока это делалось
|
||||
* ВНУТРИ денежной транзакции, замок строки кампании держался всё время сетевых
|
||||
* запросов — по два на каждую кампанию тенанта. Один зависший запрос к Директу
|
||||
* запирал деньги клиента на минуты и тормозил всё, что к ним обращается.
|
||||
*
|
||||
* Сторож смотрит на глубину транзакции в момент остановки: она обязана совпадать с
|
||||
* той, что была до запуска задачи, — то есть своей транзакции задача здесь не держит.
|
||||
*/
|
||||
it('останавливает рекламу ВНЕ денежной транзакции — замок не висит на сетевом запросе', function () {
|
||||
configureYandexDirectForSpend();
|
||||
Http::fake([
|
||||
'*/json/v5/reports' => Http::response('2500', 200),
|
||||
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 222]]]]),
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
// 400 ₽ на счету, 200 ₽ заперты под ЧУЖУЮ кампанию; списание 300 ₽ уводит
|
||||
// свободный остаток в минус → стоп всей рекламы тенанта.
|
||||
app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест');
|
||||
$campaign = makeRunningCampaign($tenant->id);
|
||||
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00');
|
||||
|
||||
$urovenVMomentOstanovki = null;
|
||||
Event::listen(AdvertisingStopped::class, function () use (&$urovenVMomentOstanovki): void {
|
||||
$urovenVMomentOstanovki = DB::transactionLevel();
|
||||
});
|
||||
|
||||
$urovenDo = DB::transactionLevel();
|
||||
app(ChargeCampaignSpendJob::class)->handle();
|
||||
|
||||
expect($urovenVMomentOstanovki)->not->toBeNull()
|
||||
->and($urovenVMomentOstanovki)->toBe($urovenDo);
|
||||
});
|
||||
|
||||
it('does nothing and sends no HTTP when the yandex_direct switch is off', function () {
|
||||
config(['services.yandex_direct.enabled' => false]);
|
||||
fakeYandexImpressionsReport();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
/**
|
||||
* Мастер обещал показы как ФАКТ: «~8 150». Клиент читал это как «столько будет».
|
||||
*
|
||||
* 🔴 Живой замер 04.08.2026 по кабинету Директа: чужая медийная кампания с бюджетом
|
||||
* 13 000 ₽ за четверо суток набрала 457 показов и потратила 138 ₽. Деньги не кончились —
|
||||
* кончились ЛЮДИ: Яндекс показывает ровно стольким, скольких из загруженного списка он
|
||||
* сумел найти в интернете, и не чаще, чем эти люди туда заходят.
|
||||
*
|
||||
* То есть число из мастера — это ПОТОЛОК, до которого кампания почти наверняка не
|
||||
* дотянет, а не обещание. Клиент узнавал правду не в мастере, а через сутки после
|
||||
* запуска, когда деньги уже заморожены.
|
||||
*
|
||||
* Отсюда два сторожа: число подаётся словами «не больше», и рядом сказано, обо что
|
||||
* упрётся и что будет с неизрасходованными деньгами.
|
||||
*/
|
||||
|
||||
const {
|
||||
createCampaignMock,
|
||||
fetchAudienceSizeMock,
|
||||
patchCampaignMock,
|
||||
fetchCampaignMock,
|
||||
fetchCampaignsMock,
|
||||
uploadCampaignPhonesMock,
|
||||
fetchBannersMock,
|
||||
uploadBannerMock,
|
||||
toggleBannerIncludedMock,
|
||||
deleteBannerMock,
|
||||
approveBannersMock,
|
||||
submitCampaignMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createCampaignMock: vi.fn(),
|
||||
fetchAudienceSizeMock: vi.fn(),
|
||||
patchCampaignMock: vi.fn(),
|
||||
fetchCampaignMock: vi.fn(),
|
||||
fetchCampaignsMock: vi.fn(),
|
||||
uploadCampaignPhonesMock: vi.fn(),
|
||||
fetchBannersMock: vi.fn(),
|
||||
uploadBannerMock: vi.fn(),
|
||||
toggleBannerIncludedMock: vi.fn(),
|
||||
deleteBannerMock: vi.fn(),
|
||||
approveBannersMock: vi.fn(),
|
||||
submitCampaignMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/advertising', () => ({
|
||||
createCampaign: createCampaignMock,
|
||||
fetchAudienceSize: fetchAudienceSizeMock,
|
||||
patchCampaign: patchCampaignMock,
|
||||
fetchCampaign: fetchCampaignMock,
|
||||
fetchCampaigns: fetchCampaignsMock,
|
||||
uploadCampaignPhones: uploadCampaignPhonesMock,
|
||||
fetchBanners: fetchBannersMock,
|
||||
uploadBanner: uploadBannerMock,
|
||||
toggleBannerIncluded: toggleBannerIncludedMock,
|
||||
deleteBanner: deleteBannerMock,
|
||||
approveBanners: approveBannersMock,
|
||||
submitCampaign: submitCampaignMock,
|
||||
}));
|
||||
|
||||
import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
|
||||
import type { BannerSlot } from '../../resources/js/api/advertising';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
const DRAFT_CAMPAIGN = {
|
||||
id: 99,
|
||||
name: 'Реклама от 04.08.2026',
|
||||
status: 'draft' as const,
|
||||
audience_days: 10,
|
||||
launched_at: null,
|
||||
};
|
||||
|
||||
const ESTIMATE = {
|
||||
size: 1630,
|
||||
min: 100,
|
||||
enough: true,
|
||||
hint: null,
|
||||
frequency: 5,
|
||||
impressions: 8150,
|
||||
cpm_rub: '500.00',
|
||||
cost_rub: '4075.00',
|
||||
};
|
||||
|
||||
const BANNER_SIZES: Array<[number, number]> = [
|
||||
[160, 600],
|
||||
[240, 400],
|
||||
[240, 600],
|
||||
[300, 250],
|
||||
[300, 300],
|
||||
[300, 500],
|
||||
[300, 600],
|
||||
[320, 480],
|
||||
[336, 280],
|
||||
[480, 320],
|
||||
[728, 90],
|
||||
[970, 250],
|
||||
[320, 50],
|
||||
[320, 100],
|
||||
[1000, 120],
|
||||
];
|
||||
|
||||
function emptySlot(w: number, h: number): BannerSlot {
|
||||
return { width: w, height: h, uploaded: false, banner_id: null, bytes: null, included: false, preview_url: null };
|
||||
}
|
||||
|
||||
const BANNERS_EMPTY = {
|
||||
approved_at: null,
|
||||
max_bytes: 153600,
|
||||
formats: ['jpg', 'jpeg', 'png', 'gif'],
|
||||
slots: BANNER_SIZES.map(([w, h]) => emptySlot(w, h)),
|
||||
};
|
||||
|
||||
async function gotoStep2(w: ReturnType<typeof mount>): Promise<void> {
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true);
|
||||
}
|
||||
|
||||
describe('CampaignWizard.vue — честное обещание показов', () => {
|
||||
beforeEach(() => {
|
||||
createCampaignMock.mockReset();
|
||||
fetchAudienceSizeMock.mockReset();
|
||||
patchCampaignMock.mockReset();
|
||||
fetchCampaignsMock.mockReset();
|
||||
fetchCampaignsMock.mockResolvedValue([]);
|
||||
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
|
||||
fetchAudienceSizeMock.mockResolvedValue(ESTIMATE);
|
||||
fetchBannersMock.mockReset();
|
||||
fetchBannersMock.mockResolvedValue(BANNERS_EMPTY);
|
||||
});
|
||||
|
||||
it('число показов подаётся как потолок «не больше», а не как обещание', async () => {
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep2(w);
|
||||
|
||||
const stroka = w.find('[data-testid="pokazy-potolok"]');
|
||||
expect(stroka.exists()).toBe(true);
|
||||
expect(stroka.text()).toContain('не больше');
|
||||
expect(stroka.text()).toContain('8150');
|
||||
});
|
||||
|
||||
it('рядом сказано, обо что упрётся и что будет с неизрасходованными деньгами', async () => {
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep2(w);
|
||||
|
||||
const poyasnenie = w.find('[data-testid="pokazy-potolok-poyasnenie"]');
|
||||
expect(poyasnenie.exists()).toBe(true);
|
||||
|
||||
const tekst = poyasnenie.text();
|
||||
// Обо что упрётся — про список людей, а не про деньги.
|
||||
expect(tekst).toContain('список');
|
||||
// Что с деньгами за непоказанное.
|
||||
expect(tekst).toContain('вернутся');
|
||||
});
|
||||
|
||||
it('в сводке перед отправкой число показов тоже названо потолком', async () => {
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep2(w);
|
||||
|
||||
// Шаг 3 (баннеры) в этой проверке не участвует — доводим до сводки напрямую.
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
(w.vm as unknown as { bannersApprovedAt: string | null }).bannersApprovedAt = '2026-08-04T00:00:00Z';
|
||||
(w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/';
|
||||
await w.vm.$nextTick();
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const svodka = w.find('[data-testid="svodka-pokazy"]');
|
||||
expect(svodka.exists()).toBe(true);
|
||||
expect(svodka.text()).toContain('не больше');
|
||||
expect(svodka.text()).toContain('8150');
|
||||
});
|
||||
|
||||
/**
|
||||
* 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию дешевле своего минимума и пишет
|
||||
* об этом по-английски — «Budget for this period cannot be less than 600 rub.» —
|
||||
* на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию и залил
|
||||
* пятнадцать картинок. Предупредить надо В МАСТЕРЕ и человеческим языком.
|
||||
*/
|
||||
it('смета ниже минимума площадки — мастер предупреждает и говорит, что делать', async () => {
|
||||
fetchAudienceSizeMock.mockResolvedValue({
|
||||
...ESTIMATE,
|
||||
frequency: 1,
|
||||
impressions: 500,
|
||||
cost_rub: '250.00',
|
||||
min_impressions: 1667,
|
||||
min_cost_rub: '833.50',
|
||||
});
|
||||
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep2(w);
|
||||
|
||||
const preduprezhdenie = w.find('[data-testid="minimum-ploshchadki"]');
|
||||
expect(preduprezhdenie.exists()).toBe(true);
|
||||
|
||||
const tekst = preduprezhdenie.text();
|
||||
expect(tekst).toContain('1667');
|
||||
expect(tekst).toContain('833');
|
||||
// Английского отказа Директа клиент видеть не должен.
|
||||
expect(w.text()).not.toContain('Budget for this period');
|
||||
});
|
||||
|
||||
it('смета выше минимума — предупреждения нет', async () => {
|
||||
fetchAudienceSizeMock.mockResolvedValue({
|
||||
...ESTIMATE,
|
||||
min_impressions: 1667,
|
||||
min_cost_rub: '833.50',
|
||||
});
|
||||
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep2(w);
|
||||
|
||||
expect(w.find('[data-testid="minimum-ploshchadki"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('слово «потолок» не появляется, пока смета ещё считается', async () => {
|
||||
let otdat: (v: unknown) => void = () => {};
|
||||
fetchAudienceSizeMock.mockReturnValue(new Promise((r) => { otdat = r; }));
|
||||
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(false);
|
||||
|
||||
otdat(ESTIMATE);
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user