76e4862f95
Приёмка Яндекса, восемь бесплатных карточек. Две проверены на живых деньгах
боевой кампании #13, шесть — сторожами на тестовой базе.
Нашлась настоящая дыра. Если запуск оборвался на полпути, деньги остаются
заперты нарочно: в Директе кампания уже завелась и может крутить показы,
клиент дожмёт «Запустить». Но выглядит она обычным черновиком, а черновик
разрешено удалить. Кампании после этого нет, а запись о заморозке ссылается
на её номер — возврат ищут по кампании и не находят. Деньги клиента заперты
навсегда, молча, без единой ошибки в журнале. Теперь такой черновик удалить
нельзя, и портал объясняет, что сделать вместо этого.
Второе: портал отказывал на языке разработчика — «Поле file должно быть
файлом одного из типов», «не может быть больше 10240 Кбайт». Клиент не знает,
что такое поле file, и не считает мегабайты в килобайтах. Четыре отказа по
файлам переписаны по образцу, который в этом же модуле уже был: «Нужен ровно
300×250. Вы загрузили 100×100.»
Сторожа приёмки:
- чужая кампания отдаёт 404 на всех двадцати одном пути, ни одного 403;
- сколько раз ни жми «Запустить» — заморозка одна;
- отклонённую в обход «Исправить» не запустить, денег не трогает;
- черновик с запертыми деньгами не уносит их;
- списание посреди запуска другой кампании — касса сходится;
- отказы по файлам сказаны человеческим языком.
Каждый принят красным: изоляция проверена подстановкой хозяина вместо чужого,
защита запуска — вырезанием прямо в коде, гонка — снятием списания из середины.
Прогоны: 413 тестов рекламы зелёные, статанализ 0.
Заодно форматирование теста минимума площадки — форматтер перенёс длинное имя
класса в шапку уже после записи 80987996e.
165 lines
7.2 KiB
PHP
165 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdCampaign;
|
|
use App\Models\AdCampaignAd;
|
|
use App\Models\AdWallet;
|
|
use App\Models\AdWalletHold;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Advertising\AdWalletService;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты:
|
|
// 01.08.2026 девятнадцать чужих проводок уронили денежную проверку приёмника МТС.
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/**
|
|
* T15 — удаление черновика кампании: DELETE /api/advertising/campaigns/{id}.
|
|
*
|
|
* Мирроим auth/tenant setup из AdvertisingCampaignEndpointTest.php — без RefreshDatabase,
|
|
* каждый тест создаёт свой Tenant::factory()->create().
|
|
*/
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
it('deletes a draft campaign of own tenant', function () {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'name' => 'Черновик на удаление',
|
|
'audience_days' => 10,
|
|
'weekly_budget_rub' => '1000.00',
|
|
]);
|
|
|
|
$response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}");
|
|
|
|
$response->assertStatus(204);
|
|
$this->assertDatabaseMissing('ad_campaigns', ['id' => $campaign->id]);
|
|
});
|
|
|
|
it('refuses to delete a running campaign', function () {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'name' => 'Запущенная кампания',
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'audience_days' => 10,
|
|
'weekly_budget_rub' => '1000.00',
|
|
]);
|
|
|
|
$response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}");
|
|
|
|
$response->assertStatus(409);
|
|
$this->assertDatabaseHas('ad_campaigns', ['id' => $campaign->id]);
|
|
});
|
|
|
|
it('returns 404 for another tenants campaign and does not delete it', function () {
|
|
$tenantB = Tenant::factory()->create();
|
|
$campaignB = AdCampaign::create([
|
|
'tenant_id' => $tenantB->id,
|
|
'name' => 'Чужой черновик',
|
|
'audience_days' => 10,
|
|
'weekly_budget_rub' => '1000.00',
|
|
]);
|
|
|
|
$response = $this->deleteJson("/api/advertising/campaigns/{$campaignB->id}");
|
|
|
|
$response->assertStatus(404);
|
|
$this->assertDatabaseHas('ad_campaigns', ['id' => $campaignB->id]);
|
|
});
|
|
|
|
it('returns 404 for a non-existent campaign', function () {
|
|
$response = $this->deleteJson('/api/advertising/campaigns/999999999');
|
|
|
|
$response->assertStatus(404);
|
|
});
|
|
|
|
it('cascades delete to related ads and phones', function () {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'name' => 'Черновик с детьми',
|
|
'audience_days' => 10,
|
|
'weekly_budget_rub' => '1000.00',
|
|
]);
|
|
|
|
$ad = AdCampaignAd::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'campaign_id' => $campaign->id,
|
|
'title' => 'Заголовок',
|
|
'text' => 'Текст объявления',
|
|
'href' => 'https://liderra.ru',
|
|
'moderation_status' => 'draft',
|
|
]);
|
|
|
|
DB::table('ad_campaign_phones')->insert([
|
|
'tenant_id' => $campaign->tenant_id,
|
|
'campaign_id' => $campaign->id,
|
|
'phone' => '79000000001',
|
|
'expires_at' => null,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}");
|
|
|
|
$response->assertStatus(204);
|
|
$this->assertDatabaseMissing('ad_campaign_ads', ['id' => $ad->id]);
|
|
$this->assertDatabaseMissing('ad_campaign_phones', ['campaign_id' => $campaign->id]);
|
|
});
|
|
|
|
/**
|
|
* Я-Л4 приёмочного листа — удаление кампании, под которой заперты деньги.
|
|
*
|
|
* Обычно одно другому не мешает: удалять разрешено только черновик, а деньги запираются
|
|
* при запуске. Но черновик с живой заморозкой — состояние достижимое: запуск морозит деньги
|
|
* ДО похода в Директ, и если Директ откажет на полпути, статус откатывается в черновик.
|
|
* Возврат заморозки при этом делает отдельный шаг, и когда-нибудь он может не отработать —
|
|
* например, процесс убьют между откатом статуса и возвратом денег.
|
|
*
|
|
* 🔴 Тогда клиент видит обычный черновик и спокойно жмёт «Удалить». Кампании больше нет,
|
|
* а запись о заморозке ссылается на несуществующий номер: деньги клиента заперты навсегда,
|
|
* и вернуть их некому — возврат ищут по кампании, которой уже нет.
|
|
*
|
|
* Требование карточки: либо удаление запрещено, либо деньги возвращаются. Молча потерянных
|
|
* денег быть не должно.
|
|
*/
|
|
it('Я-Л4: черновик с живой заморозкой не уносит деньги клиента', function () {
|
|
$wallet = app(AdWalletService::class);
|
|
$wallet->topup($this->tenant->id, '5000.00', 'yandex', 'приёмка');
|
|
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'name' => 'Черновик с деньгами',
|
|
'status' => AdCampaign::STATUS_DRAFT,
|
|
'audience_days' => 10,
|
|
'use_uploaded_list' => false,
|
|
]);
|
|
|
|
$wallet->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
|
|
expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1200.00');
|
|
|
|
$otvet = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}");
|
|
|
|
$svejy = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
|
$visitZamorozka = AdWalletHold::where('tenant_id', $this->tenant->id)
|
|
->where('source_type', 'campaign')->where('source_id', $campaign->id)
|
|
->where('status', AdWalletHold::STATUS_ACTIVE)->exists();
|
|
|
|
if ($otvet->getStatusCode() === 204) {
|
|
// Удалили — значит деньги обязаны вернуться на счёт целиком.
|
|
expect($svejy->frozen_rub)->toBe('0.00')
|
|
->and($svejy->balance_rub)->toBe('5000.00')
|
|
->and($visitZamorozka)->toBeFalse();
|
|
} else {
|
|
// Запретили — деньги остались на месте, кампания цела, и клиенту сказали почему.
|
|
expect($otvet->getStatusCode())->toBe(409)
|
|
->and($svejy->frozen_rub)->toBe('1200.00')
|
|
->and(AdCampaign::find($campaign->id))->not->toBeNull();
|
|
expect($otvet->json('message'))->not->toBeEmpty();
|
|
}
|
|
});
|