feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда

Это снимает запрет на выкат телеграм-рекламы.

Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу
client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной
ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его
клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя.

Читающая половина при этом была готова с самого начала: TelegramAudienceService
умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало
только записи, поэтому работа вышла куда меньше, чем казалось.

Сервер:
- TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по
  одному в строке либо первым столбцом таблицы, разделители запятая, точка с
  запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно.
- три ручки: GET, POST и DELETE /api/telegram/contacts.
- потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а
  возвращается признаком.

Экран, в шаге «Кому показываем»:
- сколько номеров в базе, заливка файла, очистка;
- итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200»
  без остального читалось бы как «файл зашёл полностью»;
- после заливки счётчик охвата пересчитывается сам.

Решения, которые стоит знать:
- заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у
  себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы
  избавиться — построчного удаления в интерфейсе нет. На экране это написано до
  нажатия, молчаливой потери нет.
- файл без единого годного номера отклоняется, старая база остаётся цела. Иначе
  клиент залил бы файл не того формата и потерял всё.
- замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не
  выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк.
- наружу отдаём только счётчики. Номера — персональные данные, экрану они не
  нужны и в ответах не появляются.

Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной
причине — сервер отвечал 405, блока на экране не было.

Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов
0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие,
столько же было до работы.

Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq.
Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик
client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта
работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ,
которого на dev не видно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-02 23:13:31 +03:00
parent b264e7e983
commit 00cc072e2f
8 changed files with 796 additions and 0 deletions
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Http\Controllers\Controller;
use App\Services\ClientTg\TelegramBazaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* «Моя база номеров» клиента: показать размер, залить файл, очистить.
*
* 🔴 Номера персональные данные. Наружу отдаём ТОЛЬКО счётчики: сколько принято,
* сколько повторов, сколько не похоже на номер. Сами номера не возвращаем и в логи
* не пишем экрану они не нужны, а утечь могут.
*/
final class ContactController extends Controller
{
public function __construct(private readonly TelegramBazaService $baza) {}
/** Сколько номеров в базе — этим экран решает, показывать ли пункт «Моя база». */
public function index(Request $request): JsonResponse
{
return response()->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;
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Models\ClientTg\Contact;
use App\Support\PhoneNormalizer;
use Illuminate\Support\Facades\DB;
/**
* «Моя база номеров»: разбор залитого файла и замена базы тенанта.
*
* 🔴 Почему это вообще понадобилось. Пункт «Моя база номеров» жил на экране с
* 27.07.2026, читающая половина была готова (TelegramAudienceService::fromBase), а
* писать в `client_tg_contacts` не умела ни одна строка кода. У любого клиента пункт
* всегда показывал ноль.
*
* 🔴 Загрузка ЗАМЕНЯЕТ базу целиком, а не подмешивает. Так предсказуемее: клиент
* держит базу в своей таблице и заливает её заново. Подмешивание копило бы в портале
* номера, которых у клиента давно нет, и он не смог бы от них избавиться прав на
* построчное удаление в интерфейсе нет.
*
* 🪤 GRANT на таблице SELECT, INSERT, DELETE, БЕЗ UPDATE. Поэтому замена сделана
* удалением и вставкой, а не upsert'ом: upsert упёрся бы в право на бою и молча
* правил бы ноль строк.
*/
final class TelegramBazaService
{
/**
* Потолок номеров в одной заливке.
*
* Не про производительность, а про честность: файл больше этого почти наверняка
* не база клиента, а выгрузка «всего подряд». Показов столько никто не оплатит.
*/
public const MAX_NOMEROV = 200000;
/** Вставляем пачками — один INSERT на 200 тысяч строк ложится в память драйвера. */
private const PACHKA = 1000;
/**
* Разбирает содержимое файла: номер на строку либо первый столбец таблицы.
*
* Нераспознанное не роняет разбор клиент заливает выгрузку с шапкой и лишними
* столбцами, и упасть на первой же строке «телефон» значит не принять ничего.
*
* @return array{phones: list<string>, 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<string> $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();
}
}
+48
View File
@@ -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
+47
View File
@@ -227,3 +227,50 @@ export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule>
const { data } = await apiClient.post<TelegramAutoRule>('/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<TgBazaRazmer> {
const { data } = await apiClient.get<TgBazaRazmer>('/api/telegram/contacts');
return data;
}
/**
* POST /api/telegram/contacts — залить файл с номерами.
*
* 🪤 ЗАМЕНЯЕТ базу целиком, а не добавляет к ней. Экран обязан сказать об этом до
* нажатия, иначе клиент узнает о потере после.
*/
export async function uploadTgBaza(file: File): Promise<TgBazaItog> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<TgBazaItog>('/api/telegram/contacts', form);
return data;
}
/** DELETE /api/telegram/contacts — очистить базу целиком. */
export async function clearTgBaza(): Promise<TgBazaRazmer> {
await ensureCsrfCookie();
const { data } = await apiClient.delete<TgBazaRazmer>('/api/telegram/contacts');
return data;
}
@@ -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<TelegramAudienceKind>('deals');
const audienceDays = ref('30');
const phonesText = ref('');
// ——— «Моя база номеров» ———
//
// 🔴 До 02.08.2026 этот пункт вёл в никуда: писать в таблицу не умела ни одна строка
// кода, и у любого клиента он всегда показывал ноль. Клиент выбирал «свою базу»,
// видел пустоту и решал, что портал потерял его клиентов.
const bazaCount = ref<number | null>(null);
const bazaFile = ref<File | File[] | null>(null);
const bazaIdet = ref(false);
const bazaItog = ref<TgBazaItog | null>(null);
const bazaOshibka = ref('');
async function obnovitRazmerBazy(): Promise<void> {
try {
bazaCount.value = (await fetchTgBaza()).count;
} catch {
// Размер базы — не повод рушить весь шаг: аудиторию можно выбрать и другую.
bazaCount.value = null;
}
}
/**
* Заливка файла. Итог показываем целиком — сколько принято, сколько повторов, сколько
* не разобрали: «принято 1200» без остального читается как «файл зашёл полностью».
*/
async function zalitBazu(fajly: File[] | File | null): Promise<void> {
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<void> {
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 });
</template>
</v-text-field>
<!--
«Моя база номеров». До 02.08.2026 пункт был, а заливки не было
клиент видел ноль и решал, что портал потерял его клиентов.
-->
<v-card
v-if="audienceKind === 'base'"
variant="tonal"
class="pa-3 mb-2"
data-testid="tg-baza"
>
<div class="d-flex align-center ga-2 mb-2">
<span v-if="bazaCount === null || bazaCount === 0" class="text-body-2" data-testid="tg-baza-count">
Номеров пока нет загрузите файл со своей базой.
</span>
<span v-else class="text-body-2" data-testid="tg-baza-count">
В вашей базе <strong class="num">{{ bazaCount }}</strong> номеров.
</span>
<v-progress-circular v-if="bazaIdet" indeterminate color="primary" size="18" />
</div>
<v-file-input
v-model="bazaFile"
data-testid="tg-baza-file"
label="Файл с номерами"
accept=".csv,.txt,text/csv,text/plain"
hint="По одному номеру в строке или номер первым столбцом таблицы. Новая загрузка заменит базу целиком."
persistent-hint
prepend-icon="mdi-file-upload-outline"
density="compact"
variant="outlined"
show-size
:disabled="bazaIdet"
@update:model-value="zalitBazu"
/>
<div class="d-flex align-center ga-3 mt-2">
<v-btn
variant="text"
size="small"
data-testid="tg-baza-clear"
:disabled="bazaIdet"
@click="ochistitBazu"
>
Очистить базу
</v-btn>
<span v-if="bazaItog" class="text-body-2 text-medium-emphasis" data-testid="tg-baza-itog">
Принято {{ bazaItog.accepted }};
повторов {{ bazaItog.duplicates }};
не похоже на номер {{ bazaItog.rejected }}.
<template v-if="bazaItog.oborvano">
Файл длиннее потолка взяли первые {{ bazaItog.max }}.
</template>
</span>
</div>
<div v-if="bazaOshibka" class="text-body-2 text-error mt-2" data-testid="tg-baza-error">
{{ bazaOshibka }}
</div>
</v-card>
<v-textarea
v-if="audienceKind === 'list'"
v-model="phonesText"
+7
View File
@@ -109,6 +109,13 @@ Route::middleware(['auth:sanctum', 'tenant'])->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');
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
/**
* «Моя база номеров» загрузка, замена и очистка.
*
* 🔴 Зачем появилось. Пункт «Моя база номеров» стоял на экране с 27.07.2026, а писать
* в таблицу `client_tg_contacts` НЕ УМЕЛА ни одна строка кода: ни экрана загрузки, ни
* серверной ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
* Читающая половина при этом была готова с самого начала
* `TelegramAudienceService::fromBase` умеет и нормализацию, и дубли, и стоп-лист.
*
* 🪤 Ловушка, из-за которой это не поймали 292 зелёных теста: прежние тесты «своей базы»
* САМИ вписывали контакты через Contact::create и потому доходили до состояния, до
* которого клиент дойти не мог. Проверяя источник данных, спрашивать надо не «читается
* ли он», а «кто в него пишет».
*
* Номера 7999 синтетические настоящих ПДн в тестах нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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);
});
@@ -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<typeof mount>;
function smontirovat(): Wrapper {
return mount(TelegramCampaignWizard, {
props: { balanceRub: '5000.00' },
global: { plugins: [vuetify] },
});
}
/** Выбрать «Моя база номеров» — тот же путь, которым идёт клиент. */
async function vybratBazu(w: Wrapper): Promise<void> {
await w.find('[data-testid="tg-audience-base"] input').setValue(true);
await flushPromises();
}
async function zalitFayl(w: Wrapper, imya = 'baza.csv'): Promise<void> {
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);
});
});