-
+
+
+
+
+
+
+
+ {{ loadError }}
+
+
+
-
- {{ loadError }}
-
+
+
-
-
- Пополните рекламный кошелёк — реклама остановлена.
-
+
+
+
+
-
-
-
- Кошелёк
- {{
- formatMoney(wallet.balance_rub)
- }}
-
-
-
-
-
- Заморожено
-
-
-
-
-
-
-
{{
- formatMoney(wallet.frozen_rub)
- }}
-
-
-
-
-
- Свободно
-
-
-
-
-
-
-
{{
- formatMoney(wallet.free_rub)
- }}
-
-
-
+
+
+ {{ reportError }}
+
+
+
-
- Пополнить
-
-
-
+
+
+
-
-
-
-
История операций
+
+
+ {{ vybranaKampaniya.title }}
+
+
+
+ {{ historyError }}
+
+
+
-
-
-
-
-
-
-
- Суммы на закладках — потрачено за всё время. Заморозка деньгами не считается:
- её ещё можно вернуть.
-
-
-
-
-
-
-
- {{ historyError }}
-
-
-
- Движений по кошельку пока не было.
-
-
-
-
-
-
-
{{ t.title }}
-
- {{ t.description }}
-
-
{{ formatMoment(t.created_at) }}
-
-
-
{{ formatSigned(t.amount_rub) }}
-
- баланс не изменился
-
-
-
-
-
-
+
@@ -330,6 +278,30 @@ defineExpose({ loadWallet, loadHistory, wallet });
font-weight: 500;
}
+.dve-kolonki {
+ display: grid;
+ grid-template-columns: minmax(260px, 320px) 1fr;
+ gap: 20px;
+ align-items: start;
+}
+
+.levaya {
+ position: sticky;
+ top: 24px;
+}
+
+/* На узком экране колонки встают друг под друга, липкость снимается: иначе колонка
+ денег съест пол-экрана и до ленты придётся продираться. */
+@media (max-width: 959px) {
+ .dve-kolonki {
+ grid-template-columns: 1fr;
+ }
+
+ .levaya {
+ position: static;
+ }
+}
+
/* Закладки каналов: на узком экране переносятся, а не режутся и не уезжают за край. */
.zakladki {
display: flex;
@@ -376,19 +348,4 @@ defineExpose({ loadWallet, loadHistory, wallet });
transition: none;
}
}
-
-.wallet-stat {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.wallet-stat-value {
- font-size: 24px;
- line-height: 1.2;
-}
-
-.history-row + .history-row {
- border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
-}
diff --git a/app/routes/web.php b/app/routes/web.php
index 588910d1..b73062e2 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -493,6 +493,8 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group
Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
// История движений кошелька — клиент должен уметь объяснить себе каждую заморозку.
Route::get('/wallet/transactions', 'App\Http\Controllers\Api\AdvertisingWalletController@transactions');
+ // Отчёт за период: по дням, по кампаниям, по каналам. Даты московские.
+ Route::get('/wallet/report', 'App\Http\Controllers\Api\AdvertisingWalletController@report');
// Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик +
// счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача.
diff --git a/app/tests/Feature/Advertising/KoshelyokLentaPostranichnoTest.php b/app/tests/Feature/Advertising/KoshelyokLentaPostranichnoTest.php
new file mode 100644
index 00000000..ca4dc021
--- /dev/null
+++ b/app/tests/Feature/Advertising/KoshelyokLentaPostranichnoTest.php
@@ -0,0 +1,154 @@
+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);
+});
diff --git a/app/tests/Feature/Advertising/KoshelyokOtchyotZaPeriodTest.php b/app/tests/Feature/Advertising/KoshelyokOtchyotZaPeriodTest.php
new file mode 100644
index 00000000..65fcb9b4
--- /dev/null
+++ b/app/tests/Feature/Advertising/KoshelyokOtchyotZaPeriodTest.php
@@ -0,0 +1,288 @@
+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');
+});
+
+function kampaniya(int $tenantId, string $name): AdCampaign
+{
+ return AdCampaign::create([
+ 'tenant_id' => $tenantId,
+ 'name' => $name,
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ ]);
+}
+
+/** Списание задним числом: сервис пишет «сейчас», а нам нужен свой момент. */
+function spisanieV(int $tenantId, string $kanal, int $kampaniya, string $summa, string $utc): void
+{
+ app(AdWalletService::class)->charge($tenantId, $kanal, 'campaign', $kampaniya, $summa, uniqid('k', true));
+ AdWalletTransaction::where('tenant_id', $tenantId)
+ ->where('type', 'charge')->orderByDesc('id')->limit(1)
+ ->update(['created_at' => $utc]);
+}
+
+it('🔴 списание в 21:00 UTC ложится на СЛЕДУЮЩИЙ день по Москве', function () {
+ $k = kampaniya($this->tenant->id, 'Ночная');
+ // 21:00 UTC 5 августа = 00:00 МСК 6 августа. Живой пример с боевого, запись #69.
+ spisanieV($this->tenant->id, 'yandex', $k->id, '9.00', '2026-08-05 21:00:24');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-06');
+
+ $poDnyam = collect($otchyot['by_day'])->keyBy('day');
+ expect($poDnyam['2026-08-06']['spent_rub'])->toBe('9.00')
+ ->and($poDnyam['2026-08-05']['spent_rub'])->toBe('0.00');
+});
+
+it('период берётся по московским суткам с обоих концов', function () {
+ $k = kampaniya($this->tenant->id, 'Краевая');
+ // 20:59:59 UTC 4-го = 23:59:59 МСК 4-го — в период 5–6 августа НЕ входит.
+ spisanieV($this->tenant->id, 'yandex', $k->id, '1.00', '2026-08-04 20:59:59');
+ // 20:59:59 UTC 6-го = 23:59:59 МСК 6-го — входит последней секундой.
+ spisanieV($this->tenant->id, 'yandex', $k->id, '2.00', '2026-08-06 20:59:59');
+ // 21:00:00 UTC 6-го = 00:00 МСК 7-го — уже НЕ входит.
+ spisanieV($this->tenant->id, 'yandex', $k->id, '4.00', '2026-08-06 21:00:00');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-06');
+
+ expect($otchyot['spent_total_rub'])->toBe('2.00');
+});
+
+it('пустые дни периода остаются в ответе нулями, а не выпадают', function () {
+ $k = kampaniya($this->tenant->id, 'Одиночная');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-03 09:00:00');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-01', '2026-08-05');
+
+ expect($otchyot['by_day'])->toHaveCount(5)
+ ->and(collect($otchyot['by_day'])->pluck('day')->all())
+ ->toBe(['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']);
+});
+
+it('расходом считается только списание — заморозка и пополнение мимо', function () {
+ $k = kampaniya($this->tenant->id, 'Смешанная');
+ $this->svc->freeze($this->tenant->id, 'yandex', 'campaign', $k->id, '800.00');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '12.00', '2026-08-05 09:00:00');
+ $this->svc->topup($this->tenant->id, '500.00', 'yandex', 'Ещё пополнение');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05');
+
+ expect($otchyot['spent_total_rub'])->toBe('12.00');
+});
+
+it('разрез по кампаниям: дорогие сверху, с подписью и долей', function () {
+ $dorogaya = kampaniya($this->tenant->id, 'Я5 приёмка');
+ $deshevaya = kampaniya($this->tenant->id, 'Я1 приёмка');
+ spisanieV($this->tenant->id, 'yandex', $dorogaya->id, '126.00', '2026-08-05 09:00:00');
+ spisanieV($this->tenant->id, 'yandex', $deshevaya->id, '24.00', '2026-08-05 10:00:00');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05');
+
+ expect($otchyot['by_campaign'][0]['title'])->toContain('«Я5 приёмка»')
+ ->and($otchyot['by_campaign'][0]['spent_rub'])->toBe('126.00')
+ ->and($otchyot['by_campaign'][0]['share_percent'])->toBe(84.0)
+ ->and($otchyot['by_campaign'][1]['spent_rub'])->toBe('24.00');
+});
+
+it('закладки каналов не схлопываются при отборе по каналу', function () {
+ $k = kampaniya($this->tenant->id, 'Яндексовая');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '100.00', '2026-08-05 09:00:00');
+ spisanieV($this->tenant->id, 'sms', 1, '20.00', '2026-08-05 10:00:00');
+
+ $otchyot = app(OtchyotKoshelka::class)->sobrat($this->tenant->id, '2026-08-05', '2026-08-05', 'yandex');
+
+ // Отбор сузил дни и кампании, но закладки обязаны показать ОБА канала — иначе
+ // переключиться обратно станет некуда.
+ expect(collect($otchyot['channels'])->pluck('code')->all())->toBe(['yandex', 'sms'])
+ ->and($otchyot['spent_total_rub'])->toBe('100.00')
+ ->and($otchyot['by_campaign'])->toHaveCount(1);
+});
+
+it('ручка отчёта отдаёт период, дни, кампании и каналы', function () {
+ $k = kampaniya($this->tenant->id, 'Я5 приёмка');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '126.00', '2026-08-05 09:00:00');
+ spisanieV($this->tenant->id, 'sms', 1, '27.00', '2026-08-05 10:00:00');
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
+ ->assertOk()->json();
+
+ expect($body['from'])->toBe('2026-08-05')
+ ->and($body['spent_total_rub'])->toBe('153.00')
+ ->and($body['by_campaign'][0]['title'])->toContain('«Я5 приёмка»')
+ ->and($body['by_campaign'][0]['spent_rub'])->toBe('126.00')
+ ->and($body['by_campaign'][0]['share_percent'])->toBe(82.4)
+ ->and(collect($body['channels'])->pluck('code')->all())->toBe(['yandex', 'sms']);
+});
+
+it('без дат ручка берёт последние 30 дней и не падает', function () {
+ $body = $this->getJson('/api/advertising/wallet/report')->assertOk()->json();
+
+ expect($body['by_day'])->toHaveCount(30)->and($body['spent_total_rub'])->toBe('0.00');
+});
+
+it('кривые даты не роняют ручку, а получают понятный отказ', function () {
+ $this->getJson('/api/advertising/wallet/report?from=позавчера&to=2026-08-05')
+ ->assertStatus(422);
+ // Конец раньше начала — тоже отказ, а не молча вывернутый период.
+ $this->getJson('/api/advertising/wallet/report?from=2026-08-10&to=2026-08-01')
+ ->assertStatus(422);
+});
+
+it('чужой тенант своего отчёта не видит', function () {
+ $chuzhoy = Tenant::factory()->create();
+ $chuzhoyUser = User::factory()->create(['tenant_id' => $chuzhoy->id]);
+ $k = kampaniya($this->tenant->id, 'Наша');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '50.00', '2026-08-05 09:00:00');
+
+ $body = $this->actingAs($chuzhoyUser)
+ ->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
+ ->assertOk()->json();
+
+ expect($body['spent_total_rub'])->toBe('0.00')->and($body['by_campaign'])->toBe([]);
+});
+
+it('число запросов не растёт вместе с числом кампаний', function () {
+ $kampanii = [];
+ for ($i = 0; $i < 12; $i++) {
+ $kampanii[] = kampaniya($this->tenant->id, 'Кампания '.$i);
+ }
+ foreach ($kampanii as $k) {
+ spisanieV($this->tenant->id, 'yandex', $k->id, '3.00', '2026-08-05 09:00:00');
+ }
+
+ DB::enableQueryLog();
+ // 🪤 Одного `assertOk()` мало: на любой неизвестный путь проект отвечает страницей
+ // Vue с кодом 200 (`Route::fallback` в routes/web.php). Сторож, проверяющий только
+ // код ответа, спокойно зеленел бы и без самой ручки. Требуем поле из отчёта.
+ $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')
+ ->assertOk()->assertJsonStructure(['spent_total_rub', 'by_day', 'by_campaign']);
+ $skolko = count(DB::getQueryLog());
+ DB::disableQueryLog();
+
+ // Три сводных запроса + один на имена + служебные (сессия, пользователь, контекст RLS).
+ // Порог с запасом; если число растёт линейно с кампаниями — вопрос попал внутрь перебора.
+ expect($skolko)->toBeLessThan(15);
+});
+
+/**
+ * Закладки по каналам (`channels` + `spent_total_rub`) — правило владельца 06.08.2026:
+ * «раздели списание на закладки! чтобы было чётко видно сколько человек тратит на какую
+ * рекламу». Переехало сюда из ленты (Задача 5, 06.08.2026): закладки согласованы с
+ * выбранным периодом, а не со случайной сотней последних строк.
+ */
+function zakladkaKanala(array $body, string $code): array
+{
+ foreach ($body['channels'] ?? [] as $c) {
+ if ($c['code'] === $code) {
+ return $c;
+ }
+ }
+
+ return [];
+}
+
+it('закладки канала в отчёте показывают сумму трат по каждому, сложенную по всем строкам', function () {
+ $k = kampaniya($this->tenant->id, 'Я5');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '126.00', '2026-08-05 09:00:00');
+ spisanieV($this->tenant->id, 'sms', 1, '27.00', '2026-08-05 10:00:00');
+ spisanieV($this->tenant->id, 'sms', 2, '3.00', '2026-08-05 10:05:00');
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
+
+ expect(zakladkaKanala($body, 'yandex')['title'] ?? '')->toBe('Яндекс Аудитория')
+ ->and(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('126.00')
+ ->and(zakladkaKanala($body, 'sms')['title'] ?? '')->toBe('Рассылка СМС')
+ // 27 + 3 — траты по каналу складываются, а не показывают последнюю.
+ ->and(zakladkaKanala($body, 'sms')['spent_rub'] ?? '')->toBe('30.00');
+
+ expect($body['spent_total_rub'])->toBe('156.00');
+});
+
+it('закладка канала в отчёте не считает заморозку и пополнение расходом', function () {
+ $k = kampaniya($this->tenant->id, 'Резерв');
+ $this->svc->freeze($this->tenant->id, 'yandex', 'campaign', $k->id, '850.00');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '10.00', '2026-08-05 09:00:00');
+ $this->svc->release($this->tenant->id, 'yandex', 'campaign', $k->id);
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
+
+ expect(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('10.00')
+ ->and($body['spent_total_rub'])->toBe('10.00');
+});
+
+it('сумма трат по каналу в отчёте считается по ВСЕЙ выборке периода, а не по ограниченному числу строк', function () {
+ // Один давний расход по СМС, а сверху — 120 отдельных списаний по Яндексу в тот же
+ // день. Если кто-то однажды добавит LIMIT в агрегатный запрос отчёта — сумма канала
+ // тихо «усохнет» ровно тогда, когда строк наберётся больше предела.
+ spisanieV($this->tenant->id, 'sms', 1, '40.00', '2026-08-05 08:00:00');
+
+ $stroki = [];
+ for ($i = 0; $i < 120; $i++) {
+ $stroki[] = [
+ 'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE,
+ 'amount_rub' => '-1.00', 'balance_rub_after' => '0.00', 'channel' => 'yandex',
+ 'related_type' => 'campaign', 'related_id' => 9, 'external_key' => 'y:massa:'.$i,
+ 'description' => 'Списание за рекламу (факт)',
+ 'created_at' => '2026-08-05 09:00:00',
+ ];
+ }
+ AdWalletTransaction::insert($stroki);
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
+
+ expect(zakladkaKanala($body, 'sms')['spent_rub'] ?? '')->toBe('40.00')
+ ->and(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('120.00')
+ ->and($body['spent_total_rub'])->toBe('160.00');
+});
+
+it('закладки канала не выдумываются: канала без движений в списке нет', function () {
+ $k = kampaniya($this->tenant->id, 'Одна');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-05 09:00:00');
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
+ $kody = array_column($body['channels'] ?? [], 'code');
+
+ expect($kody)->toContain('yandex')
+ ->and($kody)->not->toContain('telegram')
+ ->and($kody)->not->toContain('vk');
+});
+
+it('закладки канала в отчёте: чужие траты внутрь не попадают', function () {
+ $chuzhoy = Tenant::factory()->create();
+ AdWallet::create(['tenant_id' => $chuzhoy->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
+ app(AdWalletService::class)->topup($chuzhoy->id, '5000.00', 'yandex', 'чужое');
+ app(AdWalletService::class)->charge($chuzhoy->id, 'yandex', 'campaign', 1, '999.00', 'chuzhoy:1');
+ AdWalletTransaction::where('tenant_id', $chuzhoy->id)->where('type', 'charge')
+ ->orderByDesc('id')->limit(1)->update(['created_at' => '2026-08-05 09:00:00']);
+
+ $k = kampaniya($this->tenant->id, 'Наша');
+ spisanieV($this->tenant->id, 'yandex', $k->id, '5.00', '2026-08-05 09:00:00');
+
+ $body = $this->getJson('/api/advertising/wallet/report?from=2026-08-05&to=2026-08-05')->assertOk()->json();
+
+ expect(zakladkaKanala($body, 'yandex')['spent_rub'] ?? '')->toBe('5.00')
+ ->and($body['spent_total_rub'])->toBe('5.00');
+});
diff --git a/app/tests/Feature/Advertising/KoshelyokRaskryitieZamorozkiTest.php b/app/tests/Feature/Advertising/KoshelyokRaskryitieZamorozkiTest.php
new file mode 100644
index 00000000..4523d8fa
--- /dev/null
+++ b/app/tests/Feature/Advertising/KoshelyokRaskryitieZamorozkiTest.php
@@ -0,0 +1,156 @@
+create();
+ $yandex = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Я3 приёмка — приняли часть, вся смета заперта',
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ ]);
+ $sms = ClientSmsCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'title' => 'Августовская рассылка',
+ 'body' => 'текст',
+ 'sender_name' => 'LIDERRA',
+ 'source' => ClientSmsCampaign::SOURCE_MANUAL,
+ 'status' => 'draft',
+ ]);
+
+ $podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
+ ['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => $yandex->id],
+ ['channel' => 'sms', 'source_type' => 'campaign', 'source_id' => $sms->id],
+ ]);
+
+ // Длинное название обрезается многоточием на 40 знаках — как в ленте.
+ expect($podpisi['yandex:campaign:'.$yandex->id])
+ ->toBe('кампания №'.$yandex->id.' «Я3 приёмка — приняли часть, вся смета за…»')
+ ->and($podpisi['sms:campaign:'.$sms->id])
+ ->toBe('кампания №'.$sms->id.' «Августовская рассылка»');
+});
+
+it('не путает номера Яндекса и СМС между собой', function () {
+ $tenant = Tenant::factory()->create();
+ $yandex = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Яндекс',
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ ]);
+ $sms = ClientSmsCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'title' => 'СМС',
+ 'body' => 'текст',
+ 'sender_name' => 'LIDERRA',
+ 'source' => ClientSmsCampaign::SOURCE_MANUAL,
+ 'status' => 'draft',
+ ]);
+
+ $podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
+ ['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => $yandex->id],
+ ['channel' => 'sms', 'source_type' => 'campaign', 'source_id' => $sms->id],
+ ]);
+
+ expect($podpisi['yandex:campaign:'.$yandex->id])->toContain('«Яндекс»')
+ ->and($podpisi['sms:campaign:'.$sms->id])->toContain('«СМС»');
+});
+
+it('владельца без записи в базе подписывает одним номером, без выдумок', function () {
+ $podpisi = app(OpisanieDvizheniyaKoshelka::class)->dlyaVladeltsev([
+ ['channel' => 'yandex', 'source_type' => 'campaign', 'source_id' => 999999],
+ ]);
+
+ expect($podpisi['yandex:campaign:999999'])->toBe('кампания №999999');
+});
+
+it('отдаёт расшифровку заморозки: под что заперто и когда вернётся', function () {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create(['tenant_id' => $tenant->id]);
+ $this->actingAs($user);
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
+ $svc = app(AdWalletService::class);
+ $svc->topup($tenant->id, '10000.00', 'yandex', 'Пополнение по счёту №12');
+
+ $bolshaya = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Я3 приёмка',
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ 'shows_until' => '2026-08-06',
+ ]);
+ $malaya = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Я1 приёмка',
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ 'shows_until' => null,
+ ]);
+
+ $svc->freeze($tenant->id, 'yandex', 'campaign', $bolshaya->id, '833.50');
+ $svc->freeze($tenant->id, 'yandex', 'campaign', $malaya->id, '849.00');
+
+ $body = $this->getJson('/api/advertising/wallet')->assertOk()->json();
+
+ // Сумма расшифровки обязана сойтись с frozen_rub кошелька — иначе это не расшифровка.
+ $summa = array_reduce($body['frozen_breakdown'], fn ($s, $r) => bcadd($s, $r['amount_rub'], 2), '0.00');
+ expect($summa)->toBe($body['frozen_rub'])->toBe('1682.50');
+
+ // Крупная заморозка стоит первой.
+ expect($body['frozen_breakdown'][0]['source_id'])->toBe($malaya->id)
+ ->and($body['frozen_breakdown'][0]['amount_rub'])->toBe('849.00')
+ ->and($body['frozen_breakdown'][0]['channel_title'])->toBe('Яндекс Аудитория')
+ ->and($body['frozen_breakdown'][0]['title'])->toContain('«Я1 приёмка»')
+ // Срока нет — пишем null, а не выдуманную дату.
+ ->and($body['frozen_breakdown'][0]['returns_on'])->toBeNull()
+ ->and($body['frozen_breakdown'][1]['returns_on'])->toBe('2026-08-06');
+});
+
+it('снятая заморозка в расшифровку не попадает', function () {
+ $tenant = Tenant::factory()->create();
+ $user = User::factory()->create(['tenant_id' => $tenant->id]);
+ $this->actingAs($user);
+ AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
+ $svc = app(AdWalletService::class);
+ $svc->topup($tenant->id, '10000.00', 'yandex', 'Пополнение');
+ $c = AdCampaign::create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Ушедшая',
+ 'audience_days' => 10,
+ 'status' => AdCampaign::STATUS_RUNNING,
+ 'estimated_impressions' => 1000,
+ 'paid_impressions' => 1000,
+ 'charged_client_rub' => '0.00',
+ 'shows_until' => null,
+ ]);
+ $svc->freeze($tenant->id, 'yandex', 'campaign', $c->id, '700.00');
+ $svc->release($tenant->id, 'yandex', 'campaign', $c->id);
+
+ $body = $this->getJson('/api/advertising/wallet')->assertOk()->json();
+
+ expect($body['frozen_breakdown'])->toBe([])->and($body['frozen_rub'])->toBe('0.00');
+});
diff --git a/app/tests/Feature/Advertising/KoshelyokZakladkiPoKanalamTest.php b/app/tests/Feature/Advertising/KoshelyokZakladkiPoKanalamTest.php
deleted file mode 100644
index 0efda8cf..00000000
--- a/app/tests/Feature/Advertising/KoshelyokZakladkiPoKanalamTest.php
+++ /dev/null
@@ -1,135 +0,0 @@
-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');
-});
-
-/** Закладка канала из ответа ручки. */
-function zakladka(array $body, string $code): array
-{
- foreach ($body['channels'] ?? [] as $c) {
- if ($c['code'] === $code) {
- return $c;
- }
- }
-
- return [];
-}
-
-it('отдаёт закладки по каналам с суммой трат по каждому', function () {
- $this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '126.00', 'y:1');
- $this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '27.00', 's:1');
- $this->svc->charge($this->tenant->id, 'sms', 'campaign', 2, '3.00', 's:2');
-
- $body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
-
- expect(zakladka($body, 'yandex')['title'] ?? '')->toBe('Яндекс Аудитория')
- ->and(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('126.00')
- ->and(zakladka($body, 'sms')['title'] ?? '')->toBe('Рассылка СМС')
- // 27 + 3 — траты по каналу складываются, а не показывают последнюю.
- ->and(zakladka($body, 'sms')['spent_rub'] ?? '')->toBe('30.00');
-
- // Итог по всем каналам — та же арифметика, что и в сумме закладок.
- expect($body['spent_total_rub'] ?? '')->toBe('156.00');
-});
-
-it('заморозка и пополнение в траты не попадают — это не расход', function () {
- $this->svc->freeze($this->tenant->id, 'yandex', 'campaign', 5, '850.00');
- $this->svc->charge($this->tenant->id, 'yandex', 'campaign', 5, '10.00', 'y:5');
- $this->svc->release($this->tenant->id, 'yandex', 'campaign', 5);
-
- $body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
-
- expect(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('10.00')
- ->and($body['spent_total_rub'] ?? '')->toBe('10.00');
-});
-
-it('сумма трат считается по ВСЕЙ летописи, а не по сотне последних строк', function () {
- // Один давний расход по СМС, а сверху — сто с лишним свежих движений по Яндексу.
- $this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '40.00', 's:staryy');
-
- $stroki = [];
- for ($i = 0; $i < 120; $i++) {
- $stroki[] = [
- 'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE,
- 'amount_rub' => '-1.00', 'balance_rub_after' => '0.00', 'channel' => 'yandex',
- 'related_type' => 'campaign', 'related_id' => 9, 'external_key' => 'y:massa:'.$i,
- 'description' => 'Списание за рекламу (факт)',
- // У летописи нет `updated_at` — запись денежного движения не правится.
- 'created_at' => now()->addSeconds($i + 1),
- ];
- }
- AdWalletTransaction::insert($stroki);
-
- $body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
-
- // В ленту влезло только 100 свежих — и все они яндексовые.
- expect($body['items'])->toHaveCount(100);
- // Но давние 40 ₽ по СМС из суммы не пропали.
- expect(zakladka($body, 'sms')['spent_rub'] ?? '')->toBe('40.00')
- ->and(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('120.00')
- ->and($body['spent_total_rub'] ?? '')->toBe('160.00');
-});
-
-it('закладка показывает только свои движения', function () {
- $this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
- $this->svc->charge($this->tenant->id, 'sms', 'campaign', 1, '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 () {
- $this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
-
- $body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
- $kody = array_column($body['channels'] ?? [], 'code');
-
- expect($kody)->toContain('yandex')
- ->and($kody)->not->toContain('telegram')
- ->and($kody)->not->toContain('vk');
-});
-
-it('чужие траты в закладки не попадают', function () {
- $chuzhoy = Tenant::factory()->create();
- AdWallet::create(['tenant_id' => $chuzhoy->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
- $this->svc->topup($chuzhoy->id, '5000.00', 'yandex', 'чужое');
- $this->svc->charge($chuzhoy->id, 'yandex', 'campaign', 1, '999.00', 'chuzhoy:1');
-
- $this->svc->charge($this->tenant->id, 'yandex', 'campaign', 1, '5.00', 'y:1');
-
- $body = $this->getJson('/api/advertising/wallet/transactions')->assertOk()->json();
-
- expect(zakladka($body, 'yandex')['spent_rub'] ?? '')->toBe('5.00')
- ->and($body['spent_total_rub'] ?? '')->toBe('5.00');
-});
diff --git a/app/tests/Frontend/KoshelyokKolonkaDeneg.spec.ts b/app/tests/Frontend/KoshelyokKolonkaDeneg.spec.ts
new file mode 100644
index 00000000..08ed563e
--- /dev/null
+++ b/app/tests/Frontend/KoshelyokKolonkaDeneg.spec.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdWalletMoneyColumn from '../../resources/js/components/advertising/AdWalletMoneyColumn.vue';
+
+const vuetify = createVuetify();
+
+const koshelek = {
+ solvent: true,
+ balance_rub: '9914.00',
+ frozen_rub: '2507.00',
+ free_rub: '7407.00',
+ frozen_breakdown: [
+ {
+ channel: 'yandex', channel_title: 'Яндекс Аудитория',
+ source_type: 'campaign', source_id: 19,
+ title: 'кампания №19 «Я3 приёмка»', amount_rub: '833.50', returns_on: '2026-08-07',
+ },
+ {
+ channel: 'yandex', channel_title: 'Яндекс Аудитория',
+ source_type: 'campaign', source_id: 16,
+ title: 'кампания №16 «Я1 приёмка»', amount_rub: '849.00', returns_on: null,
+ },
+ ],
+};
+
+describe('AdWalletMoneyColumn — левая колонка денег', () => {
+ it('показывает свободно крупно, а всего и заперто — рядом', () => {
+ const w = mount(AdWalletMoneyColumn, {
+ props: { wallet: koshelek }, global: { plugins: [vuetify] },
+ });
+
+ expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
+ expect(w.find('[data-testid="ad-wallet-balance"]').text()).toMatch(/9\s914/);
+ expect(w.find('[data-testid="ad-wallet-frozen"]').text()).toMatch(/2\s507/);
+ });
+
+ it('расшифровывает заморозку строками с датой возврата', () => {
+ const w = mount(AdWalletMoneyColumn, {
+ props: { wallet: koshelek }, global: { plugins: [vuetify] },
+ });
+
+ const rows = w.findAll('[data-testid="ad-wallet-frozen-row"]');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].text()).toContain('«Я3 приёмка»');
+ expect(rows[0].text()).toMatch(/833,50/);
+ expect(rows[0].text()).toContain('вернётся 7 августа');
+ // Срока нет — так и пишем, дату не выдумываем.
+ expect(rows[1].text()).toContain('срок не задан');
+ });
+
+ it('без заморозок пишет понятную строку, а не пустое место', () => {
+ const w = mount(AdWalletMoneyColumn, {
+ props: { wallet: { ...koshelek, frozen_rub: '0.00', frozen_breakdown: [] } },
+ global: { plugins: [vuetify] },
+ });
+
+ expect(w.find('[data-testid="ad-wallet-frozen-empty"]').exists()).toBe(true);
+ expect(w.findAll('[data-testid="ad-wallet-frozen-row"]')).toHaveLength(0);
+ });
+
+ it('при solvent=false показывает заметный алерт', () => {
+ const w = mount(AdWalletMoneyColumn, {
+ props: { wallet: { ...koshelek, solvent: false } }, global: { plugins: [vuetify] },
+ });
+
+ expect(w.find('[data-testid="ad-wallet-insolvent-alert"]').exists()).toBe(true);
+ });
+
+ it('кнопка «Пополнить» шлёт событие наверх', async () => {
+ const w = mount(AdWalletMoneyColumn, {
+ props: { wallet: koshelek }, global: { plugins: [vuetify] },
+ });
+
+ await w.find('[data-testid="ad-wallet-topup-btn"]').trigger('click');
+
+ expect(w.emitted('topup')).toBeTruthy();
+ });
+});
diff --git a/app/tests/Frontend/KoshelyokLentaPoDnyam.spec.ts b/app/tests/Frontend/KoshelyokLentaPoDnyam.spec.ts
new file mode 100644
index 00000000..70f1ba83
--- /dev/null
+++ b/app/tests/Frontend/KoshelyokLentaPoDnyam.spec.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdWalletLedger from '../../resources/js/components/advertising/AdWalletLedger.vue';
+import type { AdWalletTransaction } from '../../resources/js/api/advertising';
+
+const vuetify = createVuetify();
+
+// Тип задан явно: без него `type` выводится как `string` и не подходит под союз видов.
+const dvizheniya: AdWalletTransaction[] = [
+ {
+ id: 75, type: 'charge', title: 'Списание за рекламу', amount_rub: '-4.50',
+ balance_rub_after: '9914.00', moves_balance: true, channel: 'yandex',
+ description: 'Яндекс Аудитория · кампания №21 «Я4 приёмка»',
+ created_at: '2026-08-06T05:00:23+00:00',
+ },
+ {
+ id: 74, type: 'charge', title: 'Списание за рекламу', amount_rub: '-0.50',
+ balance_rub_after: '9918.50', moves_balance: true, channel: 'yandex',
+ description: 'Яндекс Аудитория · кампания №16 «Я1 приёмка»',
+ created_at: '2026-08-06T04:00:24+00:00',
+ },
+ {
+ id: 70, type: 'release', title: 'Резерв снят', amount_rub: '724.00',
+ balance_rub_after: '9918.50', moves_balance: false, channel: 'yandex',
+ description: 'Яндекс Аудитория · кампания №13 «Я5 приёмка»',
+ created_at: '2026-08-05T21:00:16+00:00',
+ },
+];
+
+function postavit(items = dvizheniya, hasMore = false, loading = false) {
+ return mount(AdWalletLedger, {
+ props: { items, hasMore, loading }, global: { plugins: [vuetify] },
+ });
+}
+
+describe('AdWalletLedger — лента по дням', () => {
+ it('🔴 21:00 по Гринвичу попадает в СЛЕДУЮЩИЙ московский день', () => {
+ const w = postavit();
+
+ // Все три движения — один московский день, 6 августа.
+ const dni = w.findAll('[data-testid="ad-wallet-ledger-day"]');
+ expect(dni).toHaveLength(1);
+ expect(dni[0].text()).toContain('6 августа');
+ });
+
+ it('у дня стоит итог — сумма того, что реально ушло', () => {
+ const w = postavit();
+
+ // 4,50 + 0,50 = 5,00. Снятие резерва баланс не двигает и в итог дня не идёт.
+ expect(w.find('[data-testid="ad-wallet-ledger-day-total"]').text()).toMatch(/5,00/);
+ });
+
+ it('движения показаны с подписью, каналом и временем', () => {
+ const w = postavit();
+
+ const rows = w.findAll('[data-testid="ad-wallet-ledger-row"]');
+ expect(rows).toHaveLength(3);
+ expect(rows[0].text()).toContain('Списание за рекламу');
+ expect(rows[0].text()).toContain('«Я4 приёмка»');
+ // Заморозка и снятие резерва помечены отдельно.
+ expect(rows[2].text()).toContain('баланс не изменился');
+ });
+
+ it('«Показать ещё» видна только когда есть ещё, и шлёт событие', async () => {
+ const w = postavit(dvizheniya, true);
+
+ await w.find('[data-testid="ad-wallet-ledger-more"]').trigger('click');
+
+ expect(w.emitted('more')).toBeTruthy();
+ });
+
+ it('пусто — понятная строка, а не пустое место', () => {
+ const w = postavit([]);
+
+ expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(true);
+ expect(w.find('[data-testid="ad-wallet-ledger-more"]').exists()).toBe(false);
+ });
+
+ it('пока грузится — крутилка, а не «движений не было»', () => {
+ const w = postavit([], false, true);
+
+ expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(false);
+ });
+});
diff --git a/app/tests/Frontend/KoshelyokStolbikiPoDnyam.spec.ts b/app/tests/Frontend/KoshelyokStolbikiPoDnyam.spec.ts
new file mode 100644
index 00000000..d6960337
--- /dev/null
+++ b/app/tests/Frontend/KoshelyokStolbikiPoDnyam.spec.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdWalletSpendChart from '../../resources/js/components/advertising/AdWalletSpendChart.vue';
+import type { AdWalletDay } from '../../resources/js/api/advertising';
+
+const vuetify = createVuetify();
+
+// Тип задан явно: без него вывод сужает `by_channel` до формы первой строки, и день
+// без движений (пустой объект) перестаёт быть допустимым.
+const dni: AdWalletDay[] = [
+ { day: '2026-08-04', spent_rub: '3.24', by_channel: { yandex: '3.24' } },
+ { day: '2026-08-05', spent_rub: '124.00', by_channel: { yandex: '124.00' } },
+ { day: '2026-08-06', spent_rub: '0.00', by_channel: {} },
+];
+
+function postavit(days = dni, totalRub = '127.24') {
+ return mount(AdWalletSpendChart, { props: { days, totalRub }, global: { plugins: [vuetify] } });
+}
+
+describe('AdWalletSpendChart — столбики по дням', () => {
+ it('рисует столбик на каждый день периода, включая нулевые', () => {
+ const w = postavit();
+
+ expect(w.findAll('[data-testid="ad-wallet-chart-bar"]')).toHaveLength(3);
+ });
+
+ it('высота столбика считается от самого большого дня', () => {
+ const w = postavit();
+
+ const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
+ // 124,00 — самый большой день, он и есть 100%.
+ expect(bars[1].attributes('style')).toContain('height: 100%');
+ // Нулевой день не исчезает: остаётся видимая ниточка.
+ expect(bars[2].attributes('style')).toContain('height: 2px');
+ });
+
+ it('в подсказке — день и сумма человеческими словами', () => {
+ const w = postavit();
+
+ const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
+ expect(bars[1].attributes('title')).toContain('5 августа');
+ expect(bars[1].attributes('title')).toMatch(/124,00/);
+ });
+
+ it('итог за период показан крупно', () => {
+ const w = postavit();
+
+ expect(w.find('[data-testid="ad-wallet-chart-total"]').text()).toMatch(/127,24/);
+ });
+
+ it('за период ничего не потрачено — пишет об этом, а не рисует пустоту', () => {
+ const w = postavit([{ day: '2026-08-06', spent_rub: '0.00', by_channel: {} }], '0.00');
+
+ expect(w.find('[data-testid="ad-wallet-chart-empty"]').exists()).toBe(true);
+ expect(w.findAll('[data-testid="ad-wallet-chart-bar"]')).toHaveLength(0);
+ });
+
+ it('день красится цветом канала, который в этот день потратил больше всех', () => {
+ const w = postavit([
+ { day: '2026-08-05', spent_rub: '30.00', by_channel: { yandex: '10.00', sms: '20.00' } },
+ { day: '2026-08-06', spent_rub: '30.00', by_channel: { yandex: '25.00', sms: '5.00' } },
+ ], '60.00');
+
+ const bars = w.findAll('[data-testid="ad-wallet-chart-bar"]');
+ // Первый день выиграла СМС (охристый), второй — Яндекс (teal из палитры Forest).
+ expect(bars[0].attributes('style')).toContain('rgb(138, 109, 59)');
+ expect(bars[1].attributes('style')).toContain('rgb(15, 110, 86)');
+ });
+});
diff --git a/app/tests/Frontend/KoshelyokTablicaKampaniy.spec.ts b/app/tests/Frontend/KoshelyokTablicaKampaniy.spec.ts
new file mode 100644
index 00000000..7c672dea
--- /dev/null
+++ b/app/tests/Frontend/KoshelyokTablicaKampaniy.spec.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdWalletCampaignTable from '../../resources/js/components/advertising/AdWalletCampaignTable.vue';
+import type { AdWalletCampaignRow } from '../../resources/js/api/advertising';
+
+const vuetify = createVuetify();
+
+const kampanii: AdWalletCampaignRow[] = [
+ {
+ channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
+ source_id: 13, title: 'кампания №13 «Я5 приёмка»', spent_rub: '126.00', share_percent: 82.4,
+ },
+ {
+ channel: 'sms', channel_title: 'Рассылка СМС', source_type: 'campaign',
+ source_id: 5, title: 'кампания №5 «Августовская»', spent_rub: '27.00', share_percent: 17.6,
+ },
+];
+
+function postavit(rows = kampanii, selected: AdWalletCampaignRow | null = null) {
+ return mount(AdWalletCampaignTable, {
+ props: { rows, selected }, global: { plugins: [vuetify] },
+ });
+}
+
+describe('AdWalletCampaignTable — куда ушли', () => {
+ it('строка на кампанию, дорогие сверху, с каналом и долей', () => {
+ const w = postavit();
+
+ const rows = w.findAll('[data-testid="ad-wallet-campaign-row"]');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].text()).toContain('«Я5 приёмка»');
+ expect(rows[0].text()).toContain('Яндекс Аудитория');
+ expect(rows[0].text()).toMatch(/126,00/);
+ expect(rows[0].text()).toContain('82,4');
+ });
+
+ it('клик по строке просит отобрать ленту по этой кампании', async () => {
+ const w = postavit();
+
+ await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
+
+ expect(w.emitted('select')?.[0]).toEqual([kampanii[0]]);
+ });
+
+ it('повторный клик по выбранной строке снимает отбор', async () => {
+ const w = postavit(kampanii, kampanii[0]);
+
+ await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
+
+ expect(w.emitted('select')?.[0]).toEqual([null]);
+ });
+
+ it('пусто — понятная строка', () => {
+ const w = postavit([]);
+
+ expect(w.find('[data-testid="ad-wallet-campaign-empty"]').exists()).toBe(true);
+ expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(0);
+ });
+
+ it('больше десяти строк — сперва десять и кнопка «Показать все»', async () => {
+ const mnogo = Array.from({ length: 14 }, (_, i) => ({
+ channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
+ source_id: i + 1, title: `кампания №${i + 1}`,
+ spent_rub: String(100 - i) + '.00', share_percent: 7.1,
+ }));
+
+ const w = postavit(mnogo);
+ expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(10);
+
+ await w.find('[data-testid="ad-wallet-campaign-more"]').trigger('click');
+
+ expect(w.findAll('[data-testid="ad-wallet-campaign-row"]')).toHaveLength(14);
+ });
+});
diff --git a/app/tests/Frontend/KoshelyokVyborPerioda.spec.ts b/app/tests/Frontend/KoshelyokVyborPerioda.spec.ts
new file mode 100644
index 00000000..70143117
--- /dev/null
+++ b/app/tests/Frontend/KoshelyokVyborPerioda.spec.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import AdWalletPeriodPicker from '../../resources/js/components/advertising/AdWalletPeriodPicker.vue';
+
+const vuetify = createVuetify();
+
+function postavit(from = '2026-07-08', to = '2026-08-06') {
+ return mount(AdWalletPeriodPicker, { props: { from, to }, global: { plugins: [vuetify] } });
+}
+
+describe('AdWalletPeriodPicker — выбор периода', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ // 6 августа 2026, 08:00 МСК (05:00 UTC).
+ vi.setSystemTime(new Date('2026-08-06T05:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('по умолчанию отмечен «30 дней» — последние 30 суток по Москве', () => {
+ const w = postavit();
+
+ expect(w.find('[data-testid="ad-wallet-period-30"]').classes()).toContain('vybran');
+ });
+
+ it('«Сегодня» отдаёт один московский день', async () => {
+ const w = postavit();
+
+ await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
+
+ expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-06', to: '2026-08-06' }]);
+ });
+
+ it('«Вчера» отдаёт вчерашний московский день', async () => {
+ const w = postavit();
+
+ await w.find('[data-testid="ad-wallet-period-yesterday"]').trigger('click');
+
+ expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-05', to: '2026-08-05' }]);
+ });
+
+ it('«7 дней» включает сегодня и шесть предыдущих', async () => {
+ const w = postavit();
+
+ await w.find('[data-testid="ad-wallet-period-7"]').trigger('click');
+
+ expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-07-31', to: '2026-08-06' }]);
+ });
+
+ it('🔴 в 01:30 МСК «сегодня» — уже новый день, а не вчерашний', async () => {
+ // 22:30 UTC 5 августа = 01:30 МСК 6-го. Наивный расчёт по местному поясу дал бы 5-е.
+ vi.setSystemTime(new Date('2026-08-05T22:30:00Z'));
+ const w = postavit();
+
+ await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
+
+ expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-06', to: '2026-08-06' }]);
+ });
+
+ it('свои даты: перевёрнутый диапазон разворачивается, а не отдаётся вывернутым', async () => {
+ const w = postavit();
+
+ await w.find('[data-testid="ad-wallet-period-custom"]').trigger('click');
+ await w.find('[data-testid="ad-wallet-period-from"]').setValue('2026-08-10');
+ await w.find('[data-testid="ad-wallet-period-to"]').setValue('2026-08-01');
+ await w.find('[data-testid="ad-wallet-period-apply"]').trigger('click');
+
+ expect(w.emitted('change')?.[0]).toEqual([{ from: '2026-08-01', to: '2026-08-10' }]);
+ });
+});
diff --git a/app/tests/Frontend/advertising-wallet-view.spec.ts b/app/tests/Frontend/advertising-wallet-view.spec.ts
index 0609e7f8..4d799557 100644
--- a/app/tests/Frontend/advertising-wallet-view.spec.ts
+++ b/app/tests/Frontend/advertising-wallet-view.spec.ts
@@ -2,204 +2,192 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-/**
- * AdWalletView (T19 — страница «Рекламный кошелёк», план 2026-07-25).
- * Баланс через fetchWallet() (тот же источник, что и AdWalletHeader), кнопка
- * «Пополнить» открывает AdWalletTopupDialog, история операций — пометка
- * «появится позже» (клиентского эндпоинта списка операций нет).
- */
-const { fetchWalletMock, fetchWalletTransactionsMock } = vi.hoisted(() => ({
+const { fetchWalletMock, fetchReportMock, fetchTransactionsMock } = vi.hoisted(() => ({
fetchWalletMock: vi.fn(),
- // Тип задан явно: без него вывод даёт `never`, и наборы данных ниже не присвоить.
- // С 06.08.2026 ручка отдаёт не голый список, а ленту + закладки по каналам.
- fetchWalletTransactionsMock: vi.fn(
- (): Promise
=> Promise.resolve({ items: [], channels: [], spent_total_rub: '0.00' }),
- ),
+ fetchReportMock: vi.fn(),
+ fetchTransactionsMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: fetchWalletMock,
- fetchWalletTransactions: fetchWalletTransactionsMock,
+ fetchWalletReport: fetchReportMock,
+ fetchWalletTransactions: fetchTransactionsMock,
}));
import AdWalletView from '../../resources/js/views/advertising/AdWalletView.vue';
const vuetify = createVuetify();
+const koshelek = {
+ solvent: true, balance_rub: '9914.00', frozen_rub: '2507.00', free_rub: '7407.00',
+ frozen_breakdown: [{
+ channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
+ source_id: 19, title: 'кампания №19 «Я3 приёмка»', amount_rub: '833.50',
+ returns_on: '2026-08-07',
+ }],
+};
+
+const otchyot = {
+ from: '2026-07-08', to: '2026-08-06', spent_total_rub: '127.24',
+ by_day: [{ day: '2026-08-06', spent_rub: '9.00', by_channel: { yandex: '9.00' } }],
+ by_campaign: [{
+ channel: 'yandex', channel_title: 'Яндекс Аудитория', source_type: 'campaign',
+ source_id: 13, title: 'кампания №13 «Я5 приёмка»', spent_rub: '126.00', share_percent: 99.0,
+ }],
+ channels: [
+ { code: 'yandex', title: 'Яндекс Аудитория', spent_rub: '100.24' },
+ { code: 'sms', title: 'Рассылка СМС', spent_rub: '27.00' },
+ ],
+};
+
async function flush(): Promise {
await new Promise((r) => setTimeout(r, 0));
}
-describe('AdWalletView.vue (T19 — страница «Рекламный кошелёк»)', () => {
+async function postavit() {
+ const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
+ await flush();
+ await w.vm.$nextTick();
+
+ return w;
+}
+
+describe('AdWalletView — страница кошелька', () => {
beforeEach(() => {
- fetchWalletMock.mockReset();
+ fetchWalletMock.mockReset().mockResolvedValue(koshelek);
+ fetchReportMock.mockReset().mockResolvedValue(otchyot);
+ fetchTransactionsMock.mockReset().mockResolvedValue({
+ items: [], has_more: false, next_before_id: null,
+ });
});
- it('показывает баланс/заморожено/свободно из fetchWallet', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '18400.00',
- frozen_rub: '2500.00',
- free_rub: '15900.00',
+ it('при открытии дёргает все три ручки', async () => {
+ await postavit();
+
+ expect(fetchWalletMock).toHaveBeenCalledTimes(1);
+ expect(fetchReportMock).toHaveBeenCalledTimes(1);
+ expect(fetchTransactionsMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('деньги и расшифровка заморозки видны слева', async () => {
+ const w = await postavit();
+
+ expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
+ expect(w.findAll('[data-testid="ad-wallet-frozen-row"]')).toHaveLength(1);
+ });
+
+ it('смена периода перечитывает отчёт и ленту, но НЕ баланс', async () => {
+ const w = await postavit();
+
+ await w.find('[data-testid="ad-wallet-period-today"]').trigger('click');
+ await flush();
+
+ expect(fetchReportMock).toHaveBeenCalledTimes(2);
+ expect(fetchTransactionsMock).toHaveBeenCalledTimes(2);
+ // Деньги от периода не зависят — лишний запрос был бы враньём и лишней нагрузкой.
+ expect(fetchWalletMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('закладки каналов показывают название и сумму за период', async () => {
+ const w = await postavit();
+
+ expect(w.find('[data-testid="ad-wallet-tab-yandex"]').text()).toContain('Яндекс Аудитория');
+ expect(w.find('[data-testid="ad-wallet-tab-yandex"]').text()).toMatch(/100,24/);
+ expect(w.find('[data-testid="ad-wallet-tab-all"]').text()).toMatch(/127,24/);
+ });
+
+ it('выбор закладки перезапрашивает и отчёт, и ленту с этим каналом', async () => {
+ const w = await postavit();
+
+ await w.find('[data-testid="ad-wallet-tab-sms"]').trigger('click');
+ await flush();
+
+ expect(fetchReportMock).toHaveBeenLastCalledWith(expect.any(String), expect.any(String), 'sms');
+ expect(fetchTransactionsMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ channel: 'sms' }),
+ );
+ });
+
+ it('возврат на «Всё» снимает отбор по каналу', async () => {
+ const w = await postavit();
+
+ await w.find('[data-testid="ad-wallet-tab-sms"]').trigger('click');
+ await flush();
+ await w.find('[data-testid="ad-wallet-tab-all"]').trigger('click');
+ await flush();
+
+ expect(fetchReportMock).toHaveBeenLastCalledWith(expect.any(String), expect.any(String), null);
+ });
+
+ it('движений нет — закладок каналов тоже нет, экран не мусорит', async () => {
+ fetchReportMock.mockResolvedValue({
+ from: '2026-07-08', to: '2026-08-06', spent_total_rub: '0.00',
+ by_day: [], by_campaign: [], channels: [],
});
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
+ fetchTransactionsMock.mockResolvedValue({ items: [], has_more: false, next_before_id: null });
+
+ const w = await postavit();
+
+ expect(w.find('[data-testid="ad-wallet-tab-all"]').exists()).toBe(false);
+ expect(w.find('[data-testid="ad-wallet-tab-yandex"]').exists()).toBe(false);
+ expect(w.find('[data-testid="ad-wallet-ledger-empty"]').exists()).toBe(true);
+ });
+
+ it('🔴 обвал отчёта не уносит с экрана деньги', async () => {
+ fetchReportMock.mockRejectedValue(new Error('network fail'));
+
+ const w = await postavit();
+
+ expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/7\s407/);
+ expect(w.find('[data-testid="ad-wallet-report-error"]').exists()).toBe(true);
+ });
+
+ it('выбор кампании отбирает ленту и показывает снимаемую метку', async () => {
+ const w = await postavit();
+
+ await w.findAll('[data-testid="ad-wallet-campaign-row"]')[0].trigger('click');
+ await flush();
+
+ expect(fetchTransactionsMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ source_type: 'campaign', source_id: 13 }),
+ );
+ expect(w.find('[data-testid="ad-wallet-filter-chip"]').exists()).toBe(true);
+ });
+
+ it('«Показать ещё» дозагружает страницу и не задваивает строки', async () => {
+ const stroka = (id: number) => ({
+ id, type: 'charge', title: 'Списание за рекламу', amount_rub: '-1.00',
+ balance_rub_after: '0.00', moves_balance: true, channel: 'yandex',
+ description: 'Яндекс Аудитория · кампания №13', created_at: '2026-08-06T05:00:00+00:00',
+ });
+ fetchTransactionsMock
+ .mockResolvedValueOnce({ items: [stroka(2)], has_more: true, next_before_id: 2 })
+ .mockResolvedValueOnce({ items: [stroka(1)], has_more: false, next_before_id: null });
+
+ const w = await postavit();
+
+ await w.find('[data-testid="ad-wallet-ledger-more"]').trigger('click');
await flush();
await w.vm.$nextTick();
- expect(w.find('[data-testid="ad-wallet-balance"]').text()).toMatch(/18\s400/);
- expect(w.find('[data-testid="ad-wallet-frozen"]').text()).toMatch(/2\s500/);
- expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/15\s900/);
+ expect(w.findAll('[data-testid="ad-wallet-ledger-row"]')).toHaveLength(2);
});
- it('показывает заметный алерт, когда solvent=false', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: false,
- balance_rub: '0.00',
- frozen_rub: '0.00',
- free_rub: '0.00',
- });
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
-
- expect(w.find('[data-testid="ad-wallet-insolvent-alert"]').exists()).toBe(true);
- });
-
- it('кнопка «Пополнить» открывает AdWalletTopupDialog', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '1000.00',
- frozen_rub: '0.00',
- free_rub: '1000.00',
- });
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
-
- expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(false);
-
- await w.find('[data-testid="ad-wallet-topup-btn"]').trigger('click');
- await w.vm.$nextTick();
-
- expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(true);
- });
-
- /**
- * Приёмка 03.08.2026: у клиента заперты 3 333,36 ₽, и объяснения им не было нигде —
- * на экране кошелька вместо истории стояло «История операций появится позже».
- * Теперь история живая: каждое движение подписано человеческими словами и суммой.
- */
- it('показывает живую историю движений с человеческими подписями', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '9991.00',
- frozen_rub: '3333.36',
- free_rub: '6657.64',
- });
- fetchWalletTransactionsMock.mockResolvedValue({
- items: [
- {
- id: 2,
- type: 'freeze',
- title: 'Деньги зарезервированы',
- amount_rub: '-3333.36',
- balance_rub_after: '9991.00',
- moves_balance: false,
- channel: 'yandex',
- description: 'Заморозили под кампанию №6',
- created_at: '2026-07-30T13:12:00+00:00',
- },
- {
- id: 1,
- type: 'topup',
- title: 'Пополнение кошелька',
- amount_rub: '9991.00',
- balance_rub_after: '9991.00',
- moves_balance: true,
- channel: null,
- description: 'Пополнение по счёту',
- created_at: '2026-07-29T09:00:00+00:00',
- },
- ],
- channels: [{ code: 'yandex', title: 'Яндекс Аудитория', spent_rub: '0.00' }],
- spent_total_rub: '0.00',
- });
-
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
-
- const rows = w.findAll('[data-testid="ad-wallet-history-row"]');
- expect(rows).toHaveLength(2);
- expect(rows[0].text()).toContain('Деньги зарезервированы');
- expect(rows[0].text()).toContain('кампанию №6');
- expect(rows[0].text()).toMatch(/3\s333,36/);
- expect(rows[1].text()).toContain('Пополнение кошелька');
-
- // Заглушки «появится позже» больше нет.
- expect(w.find('[data-testid="ad-wallet-history-placeholder"]').exists()).toBe(false);
- });
-
- it('пустая история — понятная строка, а не пустое место', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '0.00',
- frozen_rub: '0.00',
- free_rub: '0.00',
- });
- fetchWalletTransactionsMock.mockResolvedValue({ items: [], channels: [], spent_total_rub: '0.00' });
-
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
-
- expect(w.find('[data-testid="ad-wallet-history-empty"]').exists()).toBe(true);
- expect(w.findAll('[data-testid="ad-wallet-history-row"]')).toHaveLength(0);
- });
-
- it('история не загрузилась — экран не рушится, суммы на месте', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '1000.00',
- frozen_rub: '0.00',
- free_rub: '1000.00',
- });
- fetchWalletTransactionsMock.mockRejectedValue(new Error('network fail'));
-
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
-
- expect(w.find('[data-testid="ad-wallet-balance"]').exists()).toBe(true);
- expect(w.find('[data-testid="ad-wallet-history-error"]').exists()).toBe(true);
- });
-
- it('ошибка загрузки не рушит экран — тихий inline-текст вместо сумм', async () => {
+ it('обвал баланса не рушит экран', async () => {
fetchWalletMock.mockRejectedValue(new Error('network fail'));
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
- expect(w.find('[data-testid="ad-wallet-balance"]').exists()).toBe(false);
+ const w = await postavit();
+
expect(w.find('[data-testid="ad-wallet-load-error"]').exists()).toBe(true);
});
- it('после успешного пополнения (topped-up) баланс перечитывается', async () => {
- fetchWalletMock.mockResolvedValue({
- solvent: true,
- balance_rub: '1000.00',
- frozen_rub: '0.00',
- free_rub: '1000.00',
- });
- const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } });
- await flush();
- await w.vm.$nextTick();
- expect(fetchWalletMock).toHaveBeenCalledTimes(1);
+ it('после пополнения перечитывается всё три', async () => {
+ const w = await postavit();
await w.findComponent({ name: 'AdWalletTopupDialog' }).vm.$emit('topped-up');
await flush();
- await w.vm.$nextTick();
expect(fetchWalletMock).toHaveBeenCalledTimes(2);
+ expect(fetchReportMock).toHaveBeenCalledTimes(2);
});
});