diff --git a/app/app/Http/Controllers/Api/ClientTg/ContactController.php b/app/app/Http/Controllers/Api/ClientTg/ContactController.php new file mode 100644 index 00000000..505bf555 --- /dev/null +++ b/app/app/Http/Controllers/Api/ClientTg/ContactController.php @@ -0,0 +1,80 @@ +json(['count' => $this->baza->skolko($this->tenantId($request))]); + } + + /** + * Заливка файла: разбираем, потом ЗАМЕНЯЕМ базу. + * + * 🪤 Ни одного годного номера — отказ 422 и старая база остаётся нетронутой. + * Иначе клиент залил бы файл не того формата и молча потерял всё, что было. + */ + public function store(Request $request): JsonResponse + { + $request->validate( + ['file' => 'required|file|max:10240'], + [ + 'file.required' => 'Выберите файл с номерами.', + 'file.max' => 'Файл больше 10 МБ. Разбейте базу на части.', + ], + ); + + $put = $request->file('file')->getRealPath(); + $razbor = $this->baza->razobrat($put === false ? '' : (string) file_get_contents($put)); + + if ($razbor['phones'] === []) { + return response()->json([ + 'message' => 'В файле не нашлось ни одного номера. Нужен обычный список: '. + 'по одному номеру в строке или номер первым столбцом таблицы.', + ], 422); + } + + $stalo = $this->baza->zamenit($this->tenantId($request), $razbor['phones']); + + return response()->json([ + 'count' => $stalo, + 'accepted' => $stalo, + 'duplicates' => $razbor['duplicates'], + 'rejected' => $razbor['rejected'], + // Файл длиннее потолка — говорим прямо, а не молча берём первые сколько-то. + 'oborvano' => $razbor['oborvano'], + 'max' => TelegramBazaService::MAX_NOMEROV, + ]); + } + + /** Очистка базы целиком. */ + public function destroy(Request $request): JsonResponse + { + $this->baza->ochistit($this->tenantId($request)); + + return response()->json(['count' => 0]); + } + + private function tenantId(Request $request): int + { + return (int) $request->user()->tenant_id; + } +} diff --git a/app/app/Services/ClientTg/TelegramBazaService.php b/app/app/Services/ClientTg/TelegramBazaService.php new file mode 100644 index 00000000..8f19d389 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramBazaService.php @@ -0,0 +1,140 @@ +, duplicates: int, rejected: int, oborvano: bool} + */ + public function razobrat(string $soderzhimoe): array + { + $phones = []; + $vidennye = []; + $duplicates = 0; + $rejected = 0; + $oborvano = false; + + foreach (preg_split('/\R/u', $soderzhimoe) ?: [] as $stroka) { + $stroka = trim($stroka); + if ($stroka === '') { + continue; + } + + // Таблицу отдают и с запятой, и с точкой с запятой, и с табуляцией — + // номер везде идёт первым столбцом. + $pervyyStolbets = trim((string) (preg_split('/[;,\t]/', $stroka)[0] ?? '')); + + $norm = PhoneNormalizer::normalize($pervyyStolbets); + if ($norm === null) { + $rejected++; + + continue; + } + + $goliy = substr($norm, 1); // «+7…» → «7…», как хранит таблица и ждёт читающая половина + if (isset($vidennye[$goliy])) { + $duplicates++; + + continue; + } + + $vidennye[$goliy] = true; + $phones[] = $goliy; + + if (count($phones) >= self::MAX_NOMEROV) { + $oborvano = true; + break; + } + } + + return [ + 'phones' => $phones, + 'duplicates' => $duplicates, + 'rejected' => $rejected, + 'oborvano' => $oborvano, + ]; + } + + /** + * Заменяет базу тенанта на переданный список. Возвращает, сколько номеров в ней стало. + * + * @param list $phones голые 11-значные номера + */ + public function zamenit(int $tenantId, array $phones): int + { + return DB::transaction(function () use ($tenantId, $phones): int { + // RLS-контекст сервис-слоя — как в TelegramAudienceService. Явный where по + // tenant_id ниже не лишний: боевой воркер ходит под ролью с BYPASSRLS. + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + Contact::where('tenant_id', $tenantId)->delete(); + + $seychas = now(); + foreach (array_chunk($phones, self::PACHKA) as $pachka) { + Contact::insert(array_map(fn (string $phone): array => [ + 'tenant_id' => $tenantId, + 'phone' => $phone, + 'created_at' => $seychas, + 'updated_at' => $seychas, + ], $pachka)); + } + + return count($phones); + }); + } + + /** Очищает базу тенанта целиком. */ + public function ochistit(int $tenantId): void + { + DB::transaction(function () use ($tenantId): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + Contact::where('tenant_id', $tenantId)->delete(); + }); + } + + /** Сколько номеров сейчас в базе тенанта. */ + public function skolko(int $tenantId): int + { + return Contact::where('tenant_id', $tenantId)->count(); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index c046e54e..72a44ff3 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2916,6 +2916,54 @@ parameters: count: 1 path: tests/Feature/ClientTg/AutoSafeguardTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#' + identifier: method.notFound + count: 12 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/ClientTg/BazaNomerovTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeaders\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/ClientTg/BazaNomerovTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/resources/js/api/telegram.ts b/app/resources/js/api/telegram.ts index d386447b..27dc3fc1 100644 --- a/app/resources/js/api/telegram.ts +++ b/app/resources/js/api/telegram.ts @@ -227,3 +227,50 @@ export async function uploadAutoRuleMedia(file: File): Promise const { data } = await apiClient.post('/api/telegram/auto-rule/media', form); return data; } + +// --- «Моя база номеров» (client_tg_contacts) -------------------------------- +// +// 🔴 Наружу ходят только счётчики. Номера — персональные данные, экрану они не нужны: +// он показывает «в базе N номеров», а сами номера остаются на сервере. + +/** Сколько номеров в базе тенанта. */ +export interface TgBazaRazmer { + count: number; +} + +/** Итог заливки файла: что приняли, что схлопнули, что не разобрали. */ +export interface TgBazaItog extends TgBazaRazmer { + accepted: number; + duplicates: number; + rejected: number; + /** Файл оказался длиннее потолка — взяли первые `max` номеров. */ + oborvano: boolean; + max: number; +} + +/** GET /api/telegram/contacts — размер базы. */ +export async function fetchTgBaza(): Promise { + const { data } = await apiClient.get('/api/telegram/contacts'); + return data; +} + +/** + * POST /api/telegram/contacts — залить файл с номерами. + * + * 🪤 ЗАМЕНЯЕТ базу целиком, а не добавляет к ней. Экран обязан сказать об этом до + * нажатия, иначе клиент узнает о потере после. + */ +export async function uploadTgBaza(file: File): Promise { + await ensureCsrfCookie(); + const form = new FormData(); + form.append('file', file); + const { data } = await apiClient.post('/api/telegram/contacts', form); + return data; +} + +/** DELETE /api/telegram/contacts — очистить базу целиком. */ +export async function clearTgBaza(): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.delete('/api/telegram/contacts'); + return data; +} diff --git a/app/resources/js/views/advertising/TelegramCampaignWizard.vue b/app/resources/js/views/advertising/TelegramCampaignWizard.vue index bddf4d73..6ca52f26 100644 --- a/app/resources/js/views/advertising/TelegramCampaignWizard.vue +++ b/app/resources/js/views/advertising/TelegramCampaignWizard.vue @@ -27,6 +27,10 @@ import { createTelegram, launchTelegram, uploadTelegramMedia, + fetchTgBaza, + uploadTgBaza, + clearTgBaza, + type TgBazaItog, MEDIA_ACCEPT, MEDIA_TREBOVANIYA, MEDIA_CENA, @@ -57,6 +61,68 @@ const audienceKind = ref('deals'); const audienceDays = ref('30'); const phonesText = ref(''); +// ——— «Моя база номеров» ——— +// +// 🔴 До 02.08.2026 этот пункт вёл в никуда: писать в таблицу не умела ни одна строка +// кода, и у любого клиента он всегда показывал ноль. Клиент выбирал «свою базу», +// видел пустоту и решал, что портал потерял его клиентов. +const bazaCount = ref(null); +const bazaFile = ref(null); +const bazaIdet = ref(false); +const bazaItog = ref(null); +const bazaOshibka = ref(''); + +async function obnovitRazmerBazy(): Promise { + try { + bazaCount.value = (await fetchTgBaza()).count; + } catch { + // Размер базы — не повод рушить весь шаг: аудиторию можно выбрать и другую. + bazaCount.value = null; + } +} + +/** + * Заливка файла. Итог показываем целиком — сколько принято, сколько повторов, сколько + * не разобрали: «принято 1200» без остального читается как «файл зашёл полностью». + */ +async function zalitBazu(fajly: File[] | File | null): Promise { + const fajl = Array.isArray(fajly) ? fajly[0] : fajly; + if (!fajl) { + return; + } + + bazaIdet.value = true; + bazaOshibka.value = ''; + bazaItog.value = null; + try { + const itog = await uploadTgBaza(fajl); + bazaItog.value = itog; + bazaCount.value = itog.count; + // Аудитория изменилась — счётчик охвата обязан пересчитаться, иначе он покажет + // охват прежней базы. + void obnovitOhvat(); + } catch (e: unknown) { + const otvet = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + bazaOshibka.value = otvet ?? 'Не удалось загрузить файл. Попробуйте ещё раз.'; + } finally { + bazaIdet.value = false; + } +} + +async function ochistitBazu(): Promise { + bazaIdet.value = true; + bazaOshibka.value = ''; + bazaItog.value = null; + try { + bazaCount.value = (await clearTgBaza()).count; + void obnovitOhvat(); + } catch { + bazaOshibka.value = 'Не удалось очистить базу. Попробуйте ещё раз.'; + } finally { + bazaIdet.value = false; + } +} + // ——— Шаг 2: объявление ——— const adText = ref(''); const adLink = ref(''); @@ -164,8 +230,22 @@ function otlozhennyyPereschyot(): void { watch([audienceKind, audienceDays, phonesText, mediaFile], otlozhennyyPereschyot); +// Размер базы спрашиваем ровно тогда, когда клиент на неё смотрит: остальным +// источникам аудитории он не нужен, а лишний запрос на каждом входе в мастер — нужен +// ещё меньше. +watch(audienceKind, (kind) => { + if (kind === 'base') { + bazaItog.value = null; + bazaOshibka.value = ''; + void obnovitRazmerBazy(); + } +}); + onMounted(() => { void obnovitOhvat(); + if (audienceKind.value === 'base') { + void obnovitRazmerBazy(); + } }); onUnmounted(() => { @@ -321,6 +401,66 @@ defineExpose({ step, obnovitOhvat }); + + +
+ + Номеров пока нет — загрузите файл со своей базой. + + + В вашей базе {{ bazaCount }} номеров. + + +
+ + + +
+ + Очистить базу + + + Принято {{ bazaItog.accepted }}; + повторов {{ bazaItog.duplicates }}; + не похоже на номер {{ bazaItog.rejected }}. + + +
+ +
+ {{ bazaOshibka }} +
+
+ prefix('/api/telegram')->group(fu Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id'); Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id'); + // «Моя база номеров» (client_tg_contacts): показать размер, залить файл, очистить. + // Пункт «Моя база» жил на экране с 27.07.2026, а писать в таблицу не умела ни одна + // строка кода — у любого клиента он всегда показывал ноль. + Route::get('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@index'); + Route::post('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@store'); + Route::delete('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@destroy'); + // Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета. Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show'); Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update'); diff --git a/app/tests/Feature/ClientTg/BazaNomerovTest.php b/app/tests/Feature/ClientTg/BazaNomerovTest.php new file mode 100644 index 00000000..f111f75b --- /dev/null +++ b/app/tests/Feature/ClientTg/BazaNomerovTest.php @@ -0,0 +1,145 @@ +withHeaders(['Accept' => 'application/json']); + $this->tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** Файл базы — как его выгружает обычная таблица: по номеру на строку. */ +function faylBazy(string $soderzhimoe, string $imya = 'baza.csv'): UploadedFile +{ + return UploadedFile::fake()->createWithContent($imya, $soderzhimoe); +} + +it('пустая база честно говорит про ноль, а не молчит', function () { + $this->getJson('/api/telegram/contacts') + ->assertOk() + ->assertJson(['count' => 0]); +}); + +it('загруженные номера ложатся в базу и она перестаёт быть пустой', function () { + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("79990000001\n79990000002\n79990000003\n"), + ])->assertOk()->assertJson(['count' => 3, 'accepted' => 3]); + + expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(3); +}); + +it('номера приводятся к единому виду — как их ни записали в файле', function () { + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("+7 999 000-00-01\n8(999)0000002\n9990000003\n"), + ])->assertOk()->assertJson(['accepted' => 3]); + + // Все три легли одинаково — иначе один и тот же человек попал бы в базу трижды. + expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->sort()->values()->all()) + ->toBe(['79990000001', '79990000002', '79990000003']); +}); + +it('повторы внутри файла схлопываются и считаются отдельно', function () { + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("79990000001\n8 999 000 00 01\n79990000002\n"), + ])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'duplicates' => 1]); +}); + +it('мусорные строки не роняют загрузку, а попадают в счётчик негодных', function () { + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("телефон\n79990000001\nне номер\n123\n79990000002\n"), + ])->assertOk()->assertJson(['count' => 2, 'accepted' => 2, 'rejected' => 3]); +}); + +it('вторая загрузка ЗАМЕНЯЕТ базу, а не подмешивает к старой', function () { + $this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n79990000002\n")])->assertOk(); + $this->post('/api/telegram/contacts', ['file' => faylBazy("79990000009\n")])->assertOk() + ->assertJson(['count' => 1]); + + expect(Contact::where('tenant_id', $this->tenant->id)->pluck('phone')->all())->toBe(['79990000009']); +}); + +it('базу можно очистить целиком', function () { + $this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk(); + + $this->deleteJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]); + + expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(0); +}); + +it('файл совсем без годных номеров отклоняется, а старая база остаётся цела', function () { + $this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk(); + + $this->post('/api/telegram/contacts', ['file' => faylBazy("шапка\nне номер\n")]) + ->assertStatus(422); + + // Иначе клиент залил бы кривой файл и молча потерял всю базу. + expect(Contact::where('tenant_id', $this->tenant->id)->count())->toBe(1); +}); + +it('чужую базу не видно и не тронуть', function () { + $chuzhoy = Tenant::factory()->create(); + Contact::create(['tenant_id' => $chuzhoy->id, 'phone' => '79995550001']); + + $this->getJson('/api/telegram/contacts')->assertOk()->assertJson(['count' => 0]); + + $this->post('/api/telegram/contacts', ['file' => faylBazy("79990000001\n")])->assertOk(); + + // Замена базы своего тенанта не должна была снести чужие строки. + expect(Contact::where('tenant_id', $chuzhoy->id)->count())->toBe(1); +}); + +it('после загрузки охват по своей базе наконец считается — ради этого всё и делалось', function () { + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("79990000001\n79990000002\n79990000003\n"), + ])->assertOk(); + + $this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base']) + ->assertOk() + ->assertJson(['planned_count' => 3]); +}); + +it('стоп-лист вычитается из базы — помеченный номер рекламу не увидит', function () { + Optout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']); + + $this->post('/api/telegram/contacts', [ + 'file' => faylBazy("79990000001\n79990000002\n79990000003\n"), + ])->assertOk()->assertJson(['count' => 3]); + + // В базе номер лежит, но в аудиторию не попадает — стоп-лист сильнее. + $this->postJson('/api/telegram/campaigns/estimate', ['audience_kind' => 'base']) + ->assertOk() + ->assertJson(['planned_count' => 2]); +}); + +it('без входа в портал база недоступна', function () { + app('auth')->forgetGuards(); + + $this->postJson('/api/telegram/contacts', [])->assertStatus(401); + $this->getJson('/api/telegram/contacts')->assertStatus(401); +}); diff --git a/app/tests/Frontend/telegram-baza-nomerov.spec.ts b/app/tests/Frontend/telegram-baza-nomerov.spec.ts new file mode 100644 index 00000000..25acdfbc --- /dev/null +++ b/app/tests/Frontend/telegram-baza-nomerov.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * «Моя база номеров» на экране мастера: сколько номеров, заливка файла, очистка. + * + * 🔴 Пункт «Моя база номеров» стоял в выборе аудитории с 27.07.2026 и вёл в никуда: + * писать в таблицу не умела ни одна строка кода, у любого клиента там был ноль. + * Клиент выбирал «свою базу», видел ноль и решал, что портал потерял его клиентов. + * Из-за этого выкат телеграм-рекламы был заблокирован. + * + * 🪤 Заливка ЗАМЕНЯЕТ базу целиком. Это должно быть написано на экране: молчаливая + * замена — это потеря данных, о которой клиент узнаёт после. + */ + +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +const { estimateMock, fetchBazaMock, uploadBazaMock, clearBazaMock } = vi.hoisted(() => ({ + estimateMock: vi.fn(), + fetchBazaMock: vi.fn(), + uploadBazaMock: vi.fn(), + clearBazaMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/telegram', () => ({ + estimateTelegram: estimateMock, + fetchTgBaza: fetchBazaMock, + uploadTgBaza: uploadBazaMock, + clearTgBaza: clearBazaMock, + createTelegram: vi.fn(), + launchTelegram: vi.fn(), + uploadTelegramMedia: vi.fn(), + fetchTelegram: vi.fn(), + fetchAutoRule: vi.fn(), + resubmitTelegram: vi.fn(), + uploadAutoRuleMedia: vi.fn(), + saveAutoRule: vi.fn(), + MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4', + MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880.', + MEDIA_CENA: 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.', +})); + +import TelegramCampaignWizard from '../../resources/js/views/advertising/TelegramCampaignWizard.vue'; + +const vuetify = createVuetify(); + +type Wrapper = ReturnType; + +function smontirovat(): Wrapper { + return mount(TelegramCampaignWizard, { + props: { balanceRub: '5000.00' }, + global: { plugins: [vuetify] }, + }); +} + +/** Выбрать «Моя база номеров» — тот же путь, которым идёт клиент. */ +async function vybratBazu(w: Wrapper): Promise { + await w.find('[data-testid="tg-audience-base"] input').setValue(true); + await flushPromises(); +} + +async function zalitFayl(w: Wrapper, imya = 'baza.csv'): Promise { + const file = new File(['79990000001\n79990000002\n'], imya, { type: 'text/csv' }); + const pole = w.find('[data-testid="tg-baza-file"] input[type="file"]'); + Object.defineProperty(pole.element, 'files', { value: [file] }); + await pole.trigger('change'); + await flushPromises(); +} + +describe('моя база номеров', () => { + beforeEach(() => { + vi.clearAllMocks(); + estimateMock.mockResolvedValue({ + planned_count: 512, + estimated_cost_rub: '430.08', + min_count: 367, + enough: true, + }); + fetchBazaMock.mockResolvedValue({ count: 0 }); + uploadBazaMock.mockResolvedValue({ + count: 2, + accepted: 2, + duplicates: 0, + rejected: 0, + oborvano: false, + max: 200000, + }); + clearBazaMock.mockResolvedValue({ count: 0 }); + }); + + it('пустая база говорит об этом прямо, а не показывает молчаливый ноль', async () => { + const w = smontirovat(); + await vybratBazu(w); + + const baza = w.find('[data-testid="tg-baza"]'); + expect(baza.exists()).toBe(true); + expect(baza.text()).toContain('Номеров пока нет'); + }); + + it('когда номера есть — на экране их число', async () => { + fetchBazaMock.mockResolvedValue({ count: 1240 }); + + const w = smontirovat(); + await vybratBazu(w); + + expect(w.find('[data-testid="tg-baza-count"]').text()).toContain('1240'); + }); + + it('на экране написано, что заливка заменит базу — молчаливой потери быть не должно', async () => { + const w = smontirovat(); + await vybratBazu(w); + + expect(w.find('[data-testid="tg-baza"]').text()).toContain('заменит'); + }); + + it('выбранный файл уходит на сервер', async () => { + const w = smontirovat(); + await vybratBazu(w); + await zalitFayl(w); + + expect(uploadBazaMock).toHaveBeenCalledTimes(1); + expect((uploadBazaMock.mock.calls[0][0] as File).name).toBe('baza.csv'); + }); + + it('после заливки видно, сколько номеров принято', async () => { + const w = smontirovat(); + await vybratBazu(w); + await zalitFayl(w); + + expect(w.find('[data-testid="tg-baza-itog"]').text()).toContain('2'); + expect(w.find('[data-testid="tg-baza-count"]').text()).toContain('2'); + }); + + it('повторы и негодные строки не прячутся — клиент видит, что не всё вошло', async () => { + uploadBazaMock.mockResolvedValue({ + count: 2, + accepted: 2, + duplicates: 3, + rejected: 5, + oborvano: false, + max: 200000, + }); + + const w = smontirovat(); + await vybratBazu(w); + await zalitFayl(w); + + const itog = w.find('[data-testid="tg-baza-itog"]').text(); + expect(itog).toContain('3'); + expect(itog).toContain('5'); + }); + + it('отказ сервера объясняется словами, а не пропадает', async () => { + uploadBazaMock.mockRejectedValue({ + response: { data: { message: 'В файле не нашлось ни одного номера.' } }, + }); + + const w = smontirovat(); + await vybratBazu(w); + await zalitFayl(w); + + expect(w.find('[data-testid="tg-baza-error"]').text()).toContain('не нашлось ни одного номера'); + }); + + it('базу можно очистить, и число на экране становится нулём', async () => { + fetchBazaMock.mockResolvedValue({ count: 1240 }); + + const w = smontirovat(); + await vybratBazu(w); + await w.find('[data-testid="tg-baza-clear"]').trigger('click'); + await flushPromises(); + + expect(clearBazaMock).toHaveBeenCalledTimes(1); + expect(w.find('[data-testid="tg-baza"]').text()).toContain('Номеров пока нет'); + }); + + it('на других источниках аудитории блока базы нет — он не про них', async () => { + const w = smontirovat(); + await flushPromises(); + + await w.find('[data-testid="tg-audience-list"] input').setValue(true); + await flushPromises(); + + expect(w.find('[data-testid="tg-baza"]').exists()).toBe(false); + }); +});