Files
portal/app/tests/Feature/Advertising/KoshelyokLentaPostranichnoTest.php
T
Дмитрий a709efdee4 feat(кошелёк): две колонки, период, разрез по кампаниям, расшифровка заморозки
Владелец 06.08: "кошелёк убогий и неинформативный". Отметил пять слабых мест
из шести. Не отметил только прогноз "надолго ли хватит" - он пополняет по
факту, а не по остатку.

Что теперь на экране:
- слева липкая колонка денег: свободно крупно, и под ней РАСШИФРОВКА
  заморозки - какая кампания, сколько заперто, когда вернётся. Раньше стояло
  голое "Заморожено 2 507 руб" без единого слова, под что;
- период: сегодня / вчера / 7 дней / 30 дней / свои даты, живёт в адресе
  страницы;
- столбики расхода по дням, цвет по каналу, своим CSS без библиотеки;
- таблица "Куда ушли" - строка на кампанию с суммой и долей, клик отбирает
  ленту;
- закладки по каналам сохранены, но теперь считаются за выбранный период;
- лента разбита по дням с итогом за день и постраничной догрузкой.

Сервер: две новые сборки RaskryitieZamorozki и OtchyotKoshelka, новая ручка
/api/advertising/wallet/report, лента получила отбор по датам, каналу и
кампании и листание по ключу вместо смещения. Расчёт списаний, заморозки и
возврата по сроку НЕ тронут - правка только про показ.

Найдено по дороге и закрыто:
- чтение отчёта обёрнуто контекстом построчной защиты. Без этого на боевом
  запрос вернул бы НОЛЬ строк молча: местная база под суперпользователем
  защиту обходит, и мы бы увидели это только у клиента;
- сторож на число запросов мог зеленеть БЕЗ самой ручки - на любой неизвестный
  путь портал отвечает страницей с кодом 200. Усилен и проверен вырезанием
  маршрута;
- общий форматтер срезал хвостовой ноль: "833,50" превращалось в "833,5". На
  это независимо наступили три правки подряд, каждая завела свою копию.
  Сведено в formatExact.

Границы суток и группировка по дням считаются ПО МОСКВЕ, а не по Гринвичу.
Сторож взят с живого боевого: запись 69, 2026-08-05 21:00:24 UTC - для
человека это 6 августа. Наивные версии всех трёх мест были написаны нарочно
и увидены красными, прежде чем чинились.

Старые сторожа не выброшены, а перенесены: закладки по каналам переехали в
отчёт вместе с проверкой на 120 строк, отбор ленты по каналу - в проверку
ленты. Каждый переезд назван поимённо.

Проверено: 461 сторож сервера, 2135 сторожей экрана, контроль типов ноль,
статанализ ноль, форматтер чисто, сборка фронта проходит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:18:26 +03:00

155 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Постраничная лента движений кошелька (Задача 5): отбор по датам/каналу/кампании,
* листание по ключу `before_id`, страница 50 строк.
*
* 🔴 Закладки по каналам и итог трат отсюда УЕХАЛИ в отчёт (`/wallet/report`) —
* см. KoshelyokOtchyotZaPeriodTest.php. Здесь проверяется только сама лента.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
$this->svc = app(AdWalletService::class);
$this->svc->topup($this->tenant->id, '100000.00', 'yandex', 'Пополнение по счёту №12');
// Пополнение из beforeEach иначе пишется с created_at «сейчас» и в тестах, что фильтруют
// ленту по конкретным суткам, может случайно попасть в проверяемое окно — тест не должен
// зависеть от того, какое сегодня число на календаре.
AdWalletTransaction::where('tenant_id', $this->tenant->id)->where('type', 'topup')
->update(['created_at' => '2020-01-01 00:00:00']);
});
it('отдаёт страницу в 50 строк и говорит, что есть ещё', function () {
$k = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Долгая',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
for ($i = 0; $i < 60; $i++) {
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '1.00', 'k'.$i);
}
$body = $this->getJson('/api/advertising/wallet/transactions')
->assertOk()->assertJsonStructure(['items', 'has_more'])->json();
expect($body['items'])->toHaveCount(50)
->and($body['has_more'])->toBeTrue()
->and($body['next_before_id'])->toBe($body['items'][49]['id']);
});
it('«показать ещё» не задваивает и не теряет строки', function () {
$k = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Долгая',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
for ($i = 0; $i < 60; $i++) {
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '1.00', 'k'.$i);
}
$pervaya = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
$vtoraya = $this->getJson('/api/advertising/wallet/transactions?before_id='.$pervaya['next_before_id'])
->assertOk()->json();
$ids = array_merge(
array_column($pervaya['items'], 'id'),
array_column($vtoraya['items'], 'id'),
);
expect(count($ids))->toBe(count(array_unique($ids)))
->and($vtoraya['has_more'])->toBeFalse();
});
it('отбирает ленту по кампании', function () {
$odna = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Первая',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
$drugaya = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Вторая',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $odna->id, '10.00', 'a');
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $drugaya->id, '20.00', 'b');
$body = $this->getJson('/api/advertising/wallet/transactions'
.'?source_type=campaign&source_id='.$odna->id.'&channel=yandex')->assertOk()->json();
expect($body['items'])->toHaveCount(1)
->and($body['items'][0]['description'])->toContain('«Первая»');
});
it('отбирает ленту по каналу', function () {
$k = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Смешанная',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '5.00', 'y:1');
$this->svc->charge($this->tenant->id, 'sms', 'campaign', $k->id, '7.00', 's:1');
$items = $this->getJson('/api/advertising/wallet/transactions?channel=sms')->assertOk()->json('items');
expect($items)->toHaveCount(1)
->and($items[0]['channel'])->toBe('sms')
->and($items[0]['amount_rub'])->toBe('-7.00');
});
it('отбирает ленту по московским суткам', function () {
$k = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Ночная',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 1000,
'paid_impressions' => 1000,
'charged_client_rub' => '0.00',
]);
$this->svc->charge($this->tenant->id, 'yandex', 'campaign', $k->id, '9.00', 'n');
AdWalletTransaction::where('tenant_id', $this->tenant->id)
->where('type', 'charge')->orderByDesc('id')->limit(1)
->update(['created_at' => '2026-08-05 21:00:24']);
// 21:00 UTC 5-го = 00:00 МСК 6-го: в 5 августа не попадает, в 6-е попадает.
expect($this->getJson('/api/advertising/wallet/transactions?from=2026-08-05&to=2026-08-05')
->assertOk()->json('items'))->toHaveCount(0);
expect($this->getJson('/api/advertising/wallet/transactions?from=2026-08-06&to=2026-08-06')
->assertOk()->json('items'))->toHaveCount(1);
});