diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index a3de9f51..8acf90b0 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Concerns\WritesAuthLog; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; -use App\Http\Requests\Auth\RegisterRequest; use App\Mail\SuspiciousLoginNotification; use App\Models\Tenant; use App\Models\User; @@ -131,38 +130,6 @@ class AuthController extends Controller ]); } - public function register(RegisterRequest $request): JsonResponse - { - // На MVP — attach нового user'а к первому tenant'у (для UI-разводки). - // Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли. - $tenant = Tenant::first(); - if (! $tenant) { - return response()->json([ - 'message' => 'Tenants не настроены. Обратитесь к администратору.', - ], 503); - } - - $user = User::create([ - 'tenant_id' => $tenant->id, - 'email' => $request->string('email')->toString(), - 'password_hash' => Hash::make($request->string('password')->toString()), - 'first_name' => 'Новый', - 'last_name' => 'Пользователь', - 'is_active' => true, - 'totp_enabled' => false, - ]); - - Auth::login($user); - $request->session()->regenerate(); - - $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); - - return response()->json([ - 'user' => $this->userResource($user), - 'requires_2fa' => false, - ], 201); - } - public function me(Request $request): JsonResponse { /** @var User $user */ diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index cf155101..84dce809 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -13,6 +13,7 @@ use App\Models\User; use App\Repositories\PricingTierRepository; use App\Services\Billing\BalanceToLeadsConverter; use App\Services\Billing\BillingTopupService; +use App\Services\Billing\RunwayCalculator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -318,6 +319,6 @@ class BillingController extends Controller { // F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом), // чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом. - return app(\App\Services\Billing\RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads); + return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads); } } diff --git a/app/app/Http/Controllers/Api/DashboardController.php b/app/app/Http/Controllers/Api/DashboardController.php index b6a00210..91dbc82d 100644 --- a/app/app/Http/Controllers/Api/DashboardController.php +++ b/app/app/Http/Controllers/Api/DashboardController.php @@ -6,6 +6,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Tenant; +use App\Repositories\PricingTierRepository; +use App\Services\Billing\BalanceToLeadsConverter; +use App\Services\Billing\RunwayCalculator; +use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -108,15 +112,15 @@ class DashboardController extends Controller // для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней». // Теперь — affordable leads от рублёвого баланса по тарифу // (BalanceToLeadsConverter) + общий RunwayCalculator. - $activeTiers = app(\App\Repositories\PricingTierRepository::class) - ->activeAt(\Carbon\Carbon::now('Europe/Moscow')); - $conversion = app(\App\Services\Billing\BalanceToLeadsConverter::class)->convert( + $activeTiers = app(PricingTierRepository::class) + ->activeAt(Carbon::now('Europe/Moscow')); + $conversion = app(BalanceToLeadsConverter::class)->convert( (string) $tenant->balance_rub, (int) ($tenant->delivered_in_month ?? 0), $activeTiers, ); $affordableLeads = (int) $conversion['leads']; - $runwayDays = app(\App\Services\Billing\RunwayCalculator::class) + $runwayDays = app(RunwayCalculator::class) ->daysLeft($tenantId, $affordableLeads) ?? 0; // --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index d8dbf54e..c6d55642 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\ActivityLog; use App\Models\Deal; +use App\Models\LeadCharge; use App\Models\Project; use App\Models\SupplierLeadCost; use App\Models\User; @@ -270,7 +271,7 @@ class DealController extends Controller // F2: реальная стоимость лида — снимок списания из lead_charges // (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id. - $charge = \App\Models\LeadCharge::query() + $charge = LeadCharge::query() ->where('tenant_id', $tenantId) ->where('deal_id', $id) ->first(); diff --git a/app/app/Http/Controllers/Api/RegistrationController.php b/app/app/Http/Controllers/Api/RegistrationController.php new file mode 100644 index 00000000..f01313a5 --- /dev/null +++ b/app/app/Http/Controllers/Api/RegistrationController.php @@ -0,0 +1,121 @@ +register( + $request->string('email')->toString(), + $request->string('password')->toString(), + $request->input('captcha_token'), + $request->ip(), + ); + } catch (RegistrationException $e) { + return $this->registrationError($e); + } + + $payload = [ + 'status' => $result['status'], + 'email' => $result['user']->email, + 'expires_at' => $result['verification']->expires_at->toIso8601String(), + ]; + if ($result['dev_code'] !== null) { + $payload['_dev_plain_code'] = $result['dev_code']; + } + + return response()->json($payload, 201); + } + + public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse + { + try { + $user = $service->confirm( + $request->string('email')->toString(), + $request->string('code')->toString(), + ); + } catch (RegistrationException $e) { + $payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason]; + if ($e->attemptsRemaining !== null) { + $payload['attempts_remaining'] = $e->attemptsRemaining; + } + + return response()->json($payload, 422); + } + + Auth::login($user); + $request->session()->regenerate(); + $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); + + return response()->json([ + 'user' => $this->userResource($user), + 'requires_2fa' => false, + ]); + } + + public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse + { + $devCode = $service->resend($request->string('email')->toString()); + + $payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.']; + if ($devCode !== null) { + $payload['_dev_plain_code'] = $devCode; + } + + return response()->json($payload); + } + + private function registrationError(RegistrationException $e): JsonResponse + { + $map = [ + 'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'], + 'email_taken' => ['email', 'Аккаунт с таким email уже существует.'], + ]; + [$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.']; + + return response()->json([ + 'message' => $message, + 'errors' => [$field => [$message]], + ], 422); + } + + /** @return array */ + private function userResource(User $user): array + { + return [ + 'id' => $user->id, + 'email' => $user->email, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'timezone' => $user->timezone, + 'tenant_id' => $user->tenant_id, + 'totp_enabled' => $user->totp_enabled, + 'last_login_at' => $user->last_login_at, + 'notification_preferences' => $user->notification_preferences, + 'sound_enabled' => $user->sound_enabled, + ]; + } +} diff --git a/app/app/Http/Requests/Auth/ConfirmEmailRequest.php b/app/app/Http/Requests/Auth/ConfirmEmailRequest.php new file mode 100644 index 00000000..83d9ab0b --- /dev/null +++ b/app/app/Http/Requests/Auth/ConfirmEmailRequest.php @@ -0,0 +1,32 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'code' => ['required', 'string', 'regex:/^\d{6}$/'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'email.required' => 'Укажите email.', + 'code.required' => 'Укажите код из письма.', + 'code.regex' => 'Код состоит из 6 цифр.', + ]; + } +} diff --git a/app/app/Http/Requests/Auth/RegisterRequest.php b/app/app/Http/Requests/Auth/RegisterRequest.php index 75f85b04..cc0de236 100644 --- a/app/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/app/Http/Requests/Auth/RegisterRequest.php @@ -18,14 +18,21 @@ class RegisterRequest extends FormRequest { use HasPasswordRules; - /** @return array */ + /** + * @return array + * + * NB: уникальность email НЕ через DB-rule — её решает RegistrationService + * (активный email → 422 email_taken; неподтверждённый → перевыпуск кода). + * Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST). + */ public function rules(): array { return [ - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], + 'email' => ['required', 'string', 'email', 'max:255'], 'password' => $this->passwordRules(), 'accept_offer' => ['required', 'accepted'], 'accept_pdn' => ['required', 'accepted'], + 'captcha_token' => ['required', 'string'], ]; } @@ -35,9 +42,9 @@ class RegisterRequest extends FormRequest return array_merge($this->passwordMessages(), [ 'email.required' => 'Укажите email.', 'email.email' => 'Email указан некорректно.', - 'email.unique' => 'Аккаунт с таким email уже существует.', 'accept_offer.accepted' => 'Необходимо принять оферту.', 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', + 'captcha_token.required' => 'Подтвердите, что вы не робот.', ]); } } diff --git a/app/app/Http/Requests/Auth/ResendCodeRequest.php b/app/app/Http/Requests/Auth/ResendCodeRequest.php new file mode 100644 index 00000000..543bee59 --- /dev/null +++ b/app/app/Http/Requests/Auth/ResendCodeRequest.php @@ -0,0 +1,30 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'email.required' => 'Укажите email.', + 'email.email' => 'Email указан некорректно.', + ]; + } +} diff --git a/app/app/Mail/EmailVerificationCodeMail.php b/app/app/Mail/EmailVerificationCodeMail.php new file mode 100644 index 00000000..f1984856 --- /dev/null +++ b/app/app/Mail/EmailVerificationCodeMail.php @@ -0,0 +1,41 @@ +email], + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.email_verification_code', + with: ['code' => $this->code], + ); + } +} diff --git a/app/app/Mail/NewLeadsDigestMail.php b/app/app/Mail/NewLeadsDigestMail.php index 17dd37f6..a32ba640 100644 --- a/app/app/Mail/NewLeadsDigestMail.php +++ b/app/app/Mail/NewLeadsDigestMail.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail; +use App\Models\Deal; use App\Models\Tenant; use App\Models\User; use Illuminate\Bus\Queueable; @@ -17,7 +18,7 @@ use Illuminate\Support\Collection; * Письмо-сводка о новых сделках за окно (G2-A дайджест). * Заменяет пер-лид NewLeadNotification как email-канал события new_lead. * - * @property Collection $deals + * @property Collection $deals */ class NewLeadsDigestMail extends Mailable { diff --git a/app/app/Models/EmailVerification.php b/app/app/Models/EmailVerification.php new file mode 100644 index 00000000..947128d3 --- /dev/null +++ b/app/app/Models/EmailVerification.php @@ -0,0 +1,71 @@ + 'integer', + 'expires_at' => 'datetime', + 'verified_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isUsable(): bool + { + return $this->verified_at === null + && $this->failed_attempts < 5 + && ! $this->isExpired(); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index f6df74ff..9d4f420e 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Services\Captcha\CaptchaVerifier; +use App\Services\Captcha\NullCaptchaVerifier; use App\Services\Supplier\Channel\AjaxProjectChannel; use App\Services\Supplier\Channel\FailoverProjectChannel; use App\Services\Supplier\Channel\FormProjectChannel; @@ -37,6 +39,13 @@ class AppServiceProvider extends ServiceProvider $app->make(Mailer::class), ), ); + + // Шов капчи самозаписи (G1/SP1). SP1 — Null-драйвер; реальный + // Yandex SmartCaptcha встанет сюда позже через match по драйверу. + $this->app->bind( + CaptchaVerifier::class, + NullCaptchaVerifier::class, + ); } /** @@ -49,7 +58,7 @@ class AppServiceProvider extends ServiceProvider // login / 2fa / password. Применение — throttle: в routes/web.php. // 20/мин — стартовое значение (выше максимума тестовых циклов), снижать по // боевому трафику. Runbook: docs/superpowers/runbooks/2026-06-17-auth-throttle-limits.md - foreach (['auth-login', 'auth-2fa', 'auth-password'] as $limiterName) { + foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) { RateLimiter::for( $limiterName, fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'), diff --git a/app/app/Services/Auth/RegistrationException.php b/app/app/Services/Auth/RegistrationException.php new file mode 100644 index 00000000..5f35668e --- /dev/null +++ b/app/app/Services/Auth/RegistrationException.php @@ -0,0 +1,21 @@ +captcha->verify($captchaToken, $ip)) { + throw new RegistrationException('captcha_failed'); + } + + $email = mb_strtolower(trim($email)); + $conn = DB::connection(self::DB_CONNECTION); + + // Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock). + // Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции. + $issued = $this->atomic(function () use ($conn, $email, $password) { + $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]); + + $existing = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if ($existing && $existing->is_active) { + throw new RegistrationException('email_taken'); + } + + $user = $existing ?: $this->createPendingTenantOwner($email, $password); + + return $this->createCodeRecord($user); + }); + + $this->sendCode($issued['user']->email, $issued['plain']); + + return [ + 'status' => 'pending_email_confirm', + 'user' => $issued['user'], + 'verification' => $issued['record'], + 'dev_code' => $issued['dev_code'], + ]; + } + + public function confirm(string $email, string $code): User + { + $email = mb_strtolower(trim($email)); + $user = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if (! $user) { + throw new RegistrationException('not_found'); + } + + $record = EmailVerification::on(self::DB_CONNECTION) + ->where('user_id', $user->id) + ->whereNull('verified_at') + ->orderByDesc('id') + ->first(); + + if (! $record || ! $record->isUsable()) { + $reason = $record === null ? 'not_found' + : ($record->isExpired() ? 'expired' : 'too_many_attempts'); + throw new RegistrationException($reason); + } + + if (! Hash::check($code, (string) $record->code_hash)) { + // increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил + // бы failed_attempts и сломал лимит 5 попыток). + $record->increment('failed_attempts'); + throw new RegistrationException( + 'invalid_code', + max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts), + ); + } + + // Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта. + $this->atomic(function () use ($record, $user): void { + $record->update(['verified_at' => now()]); + $user->update(['is_active' => true]); + Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([ + 'status' => 'active', + 'balance_rub' => self::START_BALANCE_RUB, + ]); + }); + + return $user->fresh(); + } + + /** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */ + public function resend(string $email): ?string + { + $email = mb_strtolower(trim($email)); + $conn = DB::connection(self::DB_CONNECTION); + + $issued = $this->atomic(function () use ($conn, $email) { + $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]); + + $user = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if ($user && ! $user->is_active) { + return $this->createCodeRecord($user); + } + + return null; + }); + + if ($issued === null) { + return null; + } + + $this->sendCode($issued['user']->email, $issued['plain']); + + return $issued['dev_code']; + } + + /** + * Выполнить $work атомарно на pgsql_supplier. + * + * Прод-путь: соединение НЕ в транзакции → открываем свою (`transaction()`), + * advisory xact-lock держится до commit/rollback — корректная сериализация. + * + * Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес + * SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions) — + * участвуем в существующей транзакции без вложенного beginTransaction: + * pgsql_supplier-connection не отслеживает уровень внешней транзакции, и + * `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой → + * «There is already an active transaction». Это nested-transaction-safety, + * не тест-специфичная ветка: повторный вызов внутри открытой транзакции + * корректно переиспользует её. + * + * @template T + * + * @param callable():T $work + * @return T + */ + private function atomic(callable $work): mixed + { + $conn = DB::connection(self::DB_CONNECTION); + + if ($conn->getPdo()->inTransaction()) { + return $work(); + } + + return $conn->transaction($work); + } + + private function createPendingTenantOwner(string $email, string $password): User + { + $tenant = Tenant::on(self::DB_CONNECTION)->create([ + 'subdomain' => $this->generateSubdomain($email), + 'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты) + 'contact_email' => $email, + 'balance_rub' => 0, + 'is_trial' => true, + ]); + // tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') — + // выставляем явно, минуя mass-assignment; иначе самозапись активировала бы + // тенанта до подтверждения почты (баг: tenant создавался 'active'). + $tenant->status = 'pending_email_confirm'; + $tenant->save(); + + return User::on(self::DB_CONNECTION)->create([ + 'tenant_id' => $tenant->id, + 'email' => $email, + 'password_hash' => Hash::make($password), + 'first_name' => 'Новый', + 'last_name' => 'клиент', + 'is_active' => false, + 'totp_enabled' => false, + ]); + } + + /** + * @return array{user:User, record:EmailVerification, plain:string, dev_code:?string} + */ + private function createCodeRecord(User $user): array + { + // Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными). + EmailVerification::on(self::DB_CONNECTION) + ->where('user_id', $user->id) + ->whereNull('verified_at') + ->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]); + + $plain = (string) random_int(100_000, 999_999); + + $record = EmailVerification::on(self::DB_CONNECTION)->create([ + 'user_id' => $user->id, + 'email' => $user->email, + 'token' => (string) Str::uuid(), + 'code_hash' => Hash::make($plain), + 'failed_attempts' => 0, + 'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES), + ]); + + return [ + 'user' => $user, + 'record' => $record, + 'plain' => $plain, + 'dev_code' => app()->environment('local', 'testing') ? $plain : null, + ]; + } + + private function sendCode(string $email, string $plain): void + { + Mail::to($email)->send(new EmailVerificationCodeMail($plain, $email)); + } + + private function generateSubdomain(string $email): string + { + $base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value(); + if ($base === '') { + $base = 'client'; + } + $base = Str::limit($base, 50, ''); + + $candidate = $base; + $i = 0; + while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) { + $i++; + $candidate = $base.$i; + } + + return Str::limit($candidate, 63, ''); + } +} diff --git a/app/app/Services/Captcha/CaptchaVerifier.php b/app/app/Services/Captcha/CaptchaVerifier.php new file mode 100644 index 00000000..79096e4f --- /dev/null +++ b/app/app/Services/Captcha/CaptchaVerifier.php @@ -0,0 +1,16 @@ +recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) { - $this->sendEmail($user, self::EVENT_NEW_LEAD, new \App\Mail\NewLeadsDigestMail($user, $tenant, $deals)); + $this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadsDigestMail($user, $tenant, $deals)); } } diff --git a/app/config/logging.php b/app/config/logging.php index 04844c48..94be49a7 100644 --- a/app/config/logging.php +++ b/app/config/logging.php @@ -1,5 +1,7 @@ env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, // PII-scrubbing: маскирует телефоны/email в записях laravel.log. - 'tap' => [\App\Logging\ScrubPii::class], + 'tap' => [ScrubPii::class], ], 'daily' => [ @@ -74,7 +76,7 @@ return [ 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true, // PII-scrubbing: маскирует телефоны/email в записях laravel.log. - 'tap' => [\App\Logging\ScrubPii::class], + 'tap' => [ScrubPii::class], ], 'slack' => [ @@ -85,7 +87,7 @@ return [ 'level' => env('LOG_LEVEL', 'critical'), 'replace_placeholders' => true, // PII-scrubbing: маскирует телефоны/email в сообщениях в Slack. - 'tap' => [\App\Logging\ScrubPii::class], + 'tap' => [ScrubPii::class], ], 'papertrail' => [ @@ -98,7 +100,7 @@ return [ 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), ], // PII-scrubbing добавлен после PsrLogMessageProcessor. - 'processors' => [PsrLogMessageProcessor::class, \App\Logging\PiiScrubbingProcessor::class], + 'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class], ], 'stderr' => [ @@ -110,7 +112,7 @@ return [ ], 'formatter' => env('LOG_STDERR_FORMATTER'), // PII-scrubbing добавлен после PsrLogMessageProcessor. - 'processors' => [PsrLogMessageProcessor::class, \App\Logging\PiiScrubbingProcessor::class], + 'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class], ], 'syslog' => [ diff --git a/app/config/services.php b/app/config/services.php index 1f046757..de9a2bb4 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -35,6 +35,14 @@ return [ ], ], + // Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test). + // Реальный Yandex SmartCaptcha подключается позже (SP3/ops). + 'captcha' => [ + 'driver' => env('CAPTCHA_DRIVER', 'null'), + 'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL), + 'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'), + ], + 'supplier' => [ 'login' => env('SUPPLIER_LOGIN'), 'password' => env('SUPPLIER_PASSWORD'), diff --git a/app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php b/app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php new file mode 100644 index 00000000..1b297bba --- /dev/null +++ b/app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php @@ -0,0 +1,45 @@ +statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)'); + $supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS failed_attempts SMALLINT NOT NULL DEFAULT 0'); + + // tenants.status был VARCHAR(20), но CHECK допускает 'pending_email_confirm' (21 симв.) — + // латентный дефект: значение не влезало в колонку. Самозапись (G1/SP1) первой реально + // ставит этот статус, поэтому расширяем до VARCHAR(30). Идемпотентно (повторный ALTER TYPE — no-op). + $supplier->statement('ALTER TABLE tenants ALTER COLUMN status TYPE VARCHAR(30)'); + + // Самозапись пишет/читает email_verifications через BYPASSRLS-роль (нет tenant-GUC + // на публичном роуте). Гарантируем права на проде (на dev — superuser, no-op). + foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) { + $supplier->statement(<<statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS failed_attempts'); + $supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS code_hash'); + } +}; diff --git a/app/resources/views/emails/email_verification_code.blade.php b/app/resources/views/emails/email_verification_code.blade.php new file mode 100644 index 00000000..3d21870c --- /dev/null +++ b/app/resources/views/emails/email_verification_code.blade.php @@ -0,0 +1,4 @@ +

Здравствуйте!

+

Ваш код подтверждения регистрации в Лидерре:

+

{{ $code }}

+

Код действует 15 минут. Если вы не регистрировались — просто проигнорируйте это письмо.

diff --git a/app/routes/console.php b/app/routes/console.php index 5968fa0b..6f669a30 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -1,5 +1,6 @@ everyThirtyMinutes() ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null)); // G2-A: дайджест новых сделок клиентам — каждые 30 минут. -Schedule::job(new \App\Jobs\SendNewLeadsDigestJob)->everyThirtyMinutes() +Schedule::job(new SendNewLeadsDigestJob)->everyThirtyMinutes() ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null)); diff --git a/app/routes/web.php b/app/routes/web.php index 9f97af6b..3f8f01c1 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -22,7 +22,12 @@ Route::prefix('/api/auth')->group(function () { // в контроллерах. Именованные лимитеры — в AppServiceProvider::boot. Route::post('/login', 'App\Http\Controllers\Api\AuthController@login') ->middleware('throttle:auth-login'); - Route::post('/register', 'App\Http\Controllers\Api\AuthController@register'); + Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register') + ->middleware('throttle:auth-register'); + Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail') + ->middleware('throttle:auth-register'); + Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode') + ->middleware('throttle:auth-register'); // /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только // pending_user_id в session. Verify завершает login после проверки TOTP. // diff --git a/app/tests/Feature/Auth/AuthControllerTest.php b/app/tests/Feature/Auth/AuthControllerTest.php index 80e5c513..c2ac7e2f 100644 --- a/app/tests/Feature/Auth/AuthControllerTest.php +++ b/app/tests/Feature/Auth/AuthControllerTest.php @@ -119,57 +119,6 @@ test('POST /api/auth/login обновляет last_login_at у user', function ( expect($user->fresh()->last_login_at)->not->toBeNull(); }); -test('POST /api/auth/register создаёт user + возвращает 201', function () { - $response = $this->postJson('/api/auth/register', [ - 'email' => 'new-signup@example.ru', - 'password' => 'fresh-pass-123', - 'accept_offer' => true, - 'accept_pdn' => true, - ]); - - $response->assertStatus(201); - $response->assertJsonPath('user.email', 'new-signup@example.ru'); - $response->assertJsonPath('requires_2fa', false); - - $user = User::where('email', 'new-signup@example.ru')->first(); - expect($user)->not->toBeNull(); - expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue(); -}); - -test('POST /api/auth/register отвергает существующий email (unique)', function () { - User::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'email' => 'duplicate@example.ru', - ]); - - $response = $this->postJson('/api/auth/register', [ - 'email' => 'duplicate@example.ru', - 'password' => 'any-password-123', - 'accept_offer' => true, - 'accept_pdn' => true, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['email']); -}); - -test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () { - $base = [ - 'email' => 'no-consent@example.ru', - 'password' => 'fresh-pass-123', - ]; - - // Без оферты. - $this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true])) - ->assertStatus(422) - ->assertJsonValidationErrors(['accept_offer']); - - // Без ПДн. - $this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true])) - ->assertStatus(422) - ->assertJsonValidationErrors(['accept_pdn']); -}); - test('GET /api/auth/me возвращает 401 без авторизации', function () { $this->getJson('/api/auth/me')->assertStatus(401); }); diff --git a/app/tests/Feature/Auth/RegistrationTest.php b/app/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 00000000..ffeb01e7 --- /dev/null +++ b/app/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,186 @@ + true]); +}); + +function registerPayload(array $over = []): array +{ + return array_merge([ + 'email' => 'newclient@example.ru', + 'password' => 'fresh-pass-123', + 'accept_offer' => true, + 'accept_pdn' => true, + 'captcha_token' => 'tok-123', + ], $over); +} + +test('register создаёт pending-тенанта + неактивного владельца + код, без входа', function () { + $r = $this->postJson('/api/auth/register', registerPayload()); + + $r->assertStatus(201); + $r->assertJsonPath('status', 'pending_email_confirm'); + $r->assertJsonPath('email', 'newclient@example.ru'); + expect($r->json('_dev_plain_code'))->toMatch('/^\d{6}$/'); + + $user = User::where('email', 'newclient@example.ru')->first(); + expect($user)->not->toBeNull(); + expect($user->is_active)->toBeFalse(); + + $tenant = Tenant::find($user->tenant_id); + expect($tenant->status)->toBe('pending_email_confirm'); + expect((float) $tenant->balance_rub)->toBe(0.0); + + // Вход НЕ выполнен. + $this->getJson('/api/auth/me')->assertStatus(401); +}); + +test('register пишет email_verifications через pgsql_supplier (BYPASSRLS)', function () { + $connections = []; + DB::listen(function ($q) use (&$connections) { + if (str_contains($q->sql, 'email_verifications')) { + $connections[] = $q->connectionName; + } + }); + + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + + expect($connections)->not->toBeEmpty(); + expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']); +}); + +test('register отклоняет неверную капчу (422), аккаунт не создаётся', function () { + config(['services.captcha.fake_passes' => false]); + + $this->postJson('/api/auth/register', registerPayload()) + ->assertStatus(422) + ->assertJsonValidationErrors(['captcha_token']); + + expect(User::where('email', 'newclient@example.ru')->exists())->toBeFalse(); +}); + +test('register требует accept_offer, accept_pdn, captcha_token', function () { + $this->postJson('/api/auth/register', registerPayload(['accept_offer' => false])) + ->assertStatus(422)->assertJsonValidationErrors(['accept_offer']); + + $this->postJson('/api/auth/register', registerPayload(['accept_pdn' => false])) + ->assertStatus(422)->assertJsonValidationErrors(['accept_pdn']); + + $payload = registerPayload(); + unset($payload['captcha_token']); + $this->postJson('/api/auth/register', $payload) + ->assertStatus(422)->assertJsonValidationErrors(['captcha_token']); +}); + +test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $code = $reg->json('_dev_plain_code'); + + $r = $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $code, + ]); + + $r->assertOk(); + $r->assertJsonPath('user.email', 'newclient@example.ru'); + $r->assertJsonPath('requires_2fa', false); + + $user = User::where('email', 'newclient@example.ru')->first(); + expect($user->is_active)->toBeTrue(); + + $tenant = Tenant::find($user->tenant_id); + expect($tenant->status)->toBe('active'); + expect((float) $tenant->balance_rub)->toBe(300.0); +}); + +test('confirm неверным кодом → 422 + failed_attempts++', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $real = $reg->json('_dev_plain_code'); + $wrong = $real === '000000' ? '111111' : '000000'; + + $r = $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $wrong, + ]); + + $r->assertStatus(422); + expect($r->json('attempts_remaining'))->toBe(4); +}); + +test('5 неверных кодов инвалидируют запись (даже верный код больше не проходит)', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $real = $reg->json('_dev_plain_code'); + $wrong = $real === '000000' ? '111111' : '000000'; + + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $wrong, + ])->assertStatus(422); + } + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $real, + ])->assertStatus(422)->assertJsonPath('reason', 'too_many_attempts'); +}); + +test('протухший код отклоняется, resend выдаёт рабочий', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $user = User::where('email', 'newclient@example.ru')->first(); + + DB::table('email_verifications')->where('user_id', $user->id) + ->update(['expires_at' => now()->subMinutes(5)]); + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $reg->json('_dev_plain_code'), + ])->assertStatus(422)->assertJsonPath('reason', 'expired'); + + $resend = $this->postJson('/api/auth/resend-code', ['email' => 'newclient@example.ru'])->assertOk(); + $newCode = $resend->json('_dev_plain_code'); + expect($newCode)->toMatch('/^\d{6}$/'); + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $newCode, + ])->assertOk(); +}); + +test('повторный register на неподтверждённый email не создаёт второго тенанта', function () { + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + + expect(User::where('email', 'newclient@example.ru')->count())->toBe(1); + expect(Tenant::where('contact_email', 'newclient@example.ru')->count())->toBe(1); +}); + +test('register на активный email → 422', function () { + $tenant = Tenant::factory()->create(); + User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'active@example.ru', + 'is_active' => true, + ]); + + $this->postJson('/api/auth/register', registerPayload(['email' => 'active@example.ru'])) + ->assertStatus(422)->assertJsonValidationErrors(['email']); +}); + +test('resend на несуществующий email → унифицированный ответ (anti-enumeration)', function () { + $this->postJson('/api/auth/resend-code', ['email' => 'nobody@example.ru']) + ->assertOk() + ->assertJsonMissingPath('_dev_plain_code'); +}); diff --git a/app/tests/Feature/DashboardSummaryTest.php b/app/tests/Feature/DashboardSummaryTest.php index b38db94e..1386edc3 100644 --- a/app/tests/Feature/DashboardSummaryTest.php +++ b/app/tests/Feature/DashboardSummaryTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Models\Deal; +use App\Models\LeadCharge; use App\Models\Project; use App\Models\Tenant; use App\Models\User; use Carbon\Carbon; use Carbon\CarbonImmutable; +use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; uses(DatabaseTransactions::class); @@ -141,10 +143,10 @@ it('runway считается от рублёвого баланса (едины // 14250₽, tier1 500₽, delivered=0 → affordable = floor(1425000/50000) = 28 лидов. // 30 списаний за 30 дней → avg 1 лид/день → runway = floor(28/1) = 28. // Те же числа, что в BillingOverviewControllerTest — доказывает единый источник. - $this->seed(\Database\Seeders\PricingTierSeeder::class); + $this->seed(PricingTierSeeder::class); $tenant = Tenant::factory()->create(['balance_rub' => '14250.00', 'balance_leads' => 285]); actingForTenant($tenant); - \App\Models\LeadCharge::factory()->count(30)->create([ + LeadCharge::factory()->count(30)->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(rand(1, 30)), ]); @@ -161,31 +163,31 @@ it('avg_lead_cost_rub = среднее rub-списаний окна, без pre // 500 + 700 + 600 = 1800 / 3 = 600 ₽. prepaid (0 ₽) и списание вне окна — не считаются. $tenant = Tenant::factory()->create(); actingForTenant($tenant); - \App\Models\LeadCharge::factory()->create([ + LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 50000, 'charged_at' => now()->subDays(1), ]); - \App\Models\LeadCharge::factory()->create([ + LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 70000, 'charged_at' => now()->subDays(2), ]); - \App\Models\LeadCharge::factory()->create([ + LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 60000, 'charged_at' => now()->subDays(3), ]); // prepaid (цена 0) — не участвует в средней - \App\Models\LeadCharge::factory()->prepaid()->create([ + LeadCharge::factory()->prepaid()->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(1), ]); // rub-списание вне окна 7d (8 дней назад) — не участвует - \App\Models\LeadCharge::factory()->create([ + LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 100000, @@ -201,7 +203,7 @@ it('avg_lead_cost_rub = null если нет rub-списаний в окне', $tenant = Tenant::factory()->create(); actingForTenant($tenant); // только prepaid — rub-списаний нет → среднего нет - \App\Models\LeadCharge::factory()->prepaid()->create([ + LeadCharge::factory()->prepaid()->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(1), ]); diff --git a/app/tests/Feature/DealShowTest.php b/app/tests/Feature/DealShowTest.php index b57dd579..39a366d3 100644 --- a/app/tests/Feature/DealShowTest.php +++ b/app/tests/Feature/DealShowTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Models\ActivityLog; use App\Models\Deal; +use App\Models\LeadCharge; use App\Models\Project; use App\Models\Tenant; use App\Models\User; @@ -228,7 +229,7 @@ test('GET /api/deals/{id} отдаёт cost_kopecks из lead_charges для rub $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); - \App\Models\LeadCharge::create([ + LeadCharge::create([ 'tenant_id' => $this->tenant->id, 'deal_id' => $deal->id, 'deal_received_at' => $deal->received_at, @@ -258,7 +259,7 @@ test('GET /api/deals/{id} cost_kopecks = null для prepaid-списания', $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(); DB::statement('SET app.current_tenant_id = '.$this->tenant->id); - \App\Models\LeadCharge::create([ + LeadCharge::create([ 'tenant_id' => $this->tenant->id, 'deal_id' => $deal->id, 'deal_received_at' => $deal->received_at, diff --git a/app/tests/Feature/Imitation/FakeDaDataClientTest.php b/app/tests/Feature/Imitation/FakeDaDataClientTest.php index e78be8a6..3dc7a947 100644 --- a/app/tests/Feature/Imitation/FakeDaDataClientTest.php +++ b/app/tests/Feature/Imitation/FakeDaDataClientTest.php @@ -6,9 +6,9 @@ use App\Models\SupplierLead; use App\Models\SupplierProject; use App\Services\DaData\DaDataPhoneClient; use App\Services\LeadRegionResolver; -use Tests\Support\Imitation\FakeDaDataPhoneClient; use Illuminate\Foundation\Testing\DatabaseTransactions; use Tests\Concerns\SharesSupplierPdo; +use Tests\Support\Imitation\FakeDaDataPhoneClient; uses(DatabaseTransactions::class); uses(SharesSupplierPdo::class); @@ -19,11 +19,10 @@ uses(SharesSupplierPdo::class); * * Task 1: Подставной DaData-клиент (group imitation). */ - it('resolves region via dadata stub qc=0 for Москва', function (): void { config(['services.dadata.enabled' => true]); - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $fake->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); @@ -31,8 +30,8 @@ it('resolves region via dadata stub qc=0 for Москва', function (): void { $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => '79990000077', - 'raw_payload' => ['tag' => ''], + 'phone' => '79990000077', + 'raw_payload' => ['tag' => ''], ]); $res = app(LeadRegionResolver::class)->resolve($lead); @@ -42,7 +41,7 @@ it('resolves region via dadata stub qc=0 for Москва', function (): void { })->group('imitation'); it('fake dadata phone client stub method returns self for fluent api', function (): void { - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: null); expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class); })->group('imitation'); @@ -50,7 +49,7 @@ it('fake dadata phone client stub method returns self for fluent api', function it('falls through to tag-fallback on qc=2 stub (empty tag → unknown)', function (): void { config(['services.dadata.enabled' => true]); - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; // qc=2 → мусор/иностранец → резолвер сразу уходит на tag-fallback (Россвязь не зовётся). $fake->stub('79990000077', qc: 2, region: null, provider: null); @@ -59,8 +58,8 @@ it('falls through to tag-fallback on qc=2 stub (empty tag → unknown)', functio $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => '79990000077', - 'raw_payload' => ['tag' => ''], + 'phone' => '79990000077', + 'raw_payload' => ['tag' => ''], ]); $res = app(LeadRegionResolver::class)->resolve($lead); diff --git a/app/tests/Feature/Imitation/FakeDaDataPhoneClientTest.php b/app/tests/Feature/Imitation/FakeDaDataPhoneClientTest.php index 8ca412c5..945369aa 100644 --- a/app/tests/Feature/Imitation/FakeDaDataPhoneClientTest.php +++ b/app/tests/Feature/Imitation/FakeDaDataPhoneClientTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); use App\Services\DaData\DaDataException; use App\Services\DaData\DaDataPhoneClient; -use Tests\Support\Imitation\FakeDaDataPhoneClient; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Tests\Support\Imitation\FakeDaDataPhoneClient; uses(DatabaseTransactions::class); @@ -14,24 +14,23 @@ uses(DatabaseTransactions::class); * Integration tests are in FakeDaDataClientTest.php. * Task 1 — Phase 1 Portal Client Imitation Harness. */ - it('fake phone client is subtype of DaDataPhoneClient', function (): void { - expect(new FakeDaDataPhoneClient())->toBeInstanceOf(DaDataPhoneClient::class); + expect(new FakeDaDataPhoneClient)->toBeInstanceOf(DaDataPhoneClient::class); })->group('imitation'); it('stub method returns self for fluent chaining', function (): void { - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: 'МТС'); expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class); })->group('imitation'); it('cleanPhone throws DaDataException when no stub registered', function (): void { - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; expect(fn () => $fake->cleanPhone('79990000099'))->toThrow(DaDataException::class); })->group('imitation'); it('stubThrows registers exception for cleanPhone', function (): void { - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $fake->stubThrows('79990000002'); expect(fn () => $fake->cleanPhone('79990000002'))->toThrow(DaDataException::class); })->group('imitation'); diff --git a/app/tests/Feature/Imitation/ImitationSeedCommandTest.php b/app/tests/Feature/Imitation/ImitationSeedCommandTest.php index 8b1e33ff..ae46e68a 100644 --- a/app/tests/Feature/Imitation/ImitationSeedCommandTest.php +++ b/app/tests/Feature/Imitation/ImitationSeedCommandTest.php @@ -12,7 +12,7 @@ use Tests\Concerns\SharesSupplierPdo; uses(DatabaseTransactions::class, SharesSupplierPdo::class); beforeEach(function (): void { - (new PricingTierSeeder())->run(); + (new PricingTierSeeder)->run(); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); }); diff --git a/app/tests/Feature/Imitation/LeadInjectorTest.php b/app/tests/Feature/Imitation/LeadInjectorTest.php index 95b84d42..a807eddb 100644 --- a/app/tests/Feature/Imitation/LeadInjectorTest.php +++ b/app/tests/Feature/Imitation/LeadInjectorTest.php @@ -22,7 +22,7 @@ beforeEach(function (): void { $this->seed(PricingTierSeeder::class); // DaData stub: phone '79991112233' resolves to Moscow (code 82) - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $fake->stub('79991112233', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); @@ -61,7 +61,7 @@ it('site() creates SupplierLead, dispatches job, returns processed lead with a d createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru'); // Act: inject a synthetic site lead (dispatchSync = synchronous) - $lead = (new LeadInjector())->site( + $lead = (new LeadInjector)->site( domain: 'vashinvestor.ru', phone: '79991112233', tag: 'Москва', diff --git a/app/tests/Feature/Imitation/RegionResolverCascadeTest.php b/app/tests/Feature/Imitation/RegionResolverCascadeTest.php index ab863c05..0ccc8682 100644 --- a/app/tests/Feature/Imitation/RegionResolverCascadeTest.php +++ b/app/tests/Feature/Imitation/RegionResolverCascadeTest.php @@ -32,12 +32,13 @@ declare(strict_types=1); * Same reason. FINDING (F3). */ +use App\Jobs\RouteSupplierLeadJob; use App\Models\SupplierLead; use App\Models\SupplierProject; use App\Services\DaData\DaDataPhoneClient; -use App\Services\Jobs\RouteSupplierLeadJob as RouteSupplierLeadJobAlias; use App\Services\LeadRegionResolver; use App\Support\RussianRegions; +use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Tests\Concerns\SharesSupplierPdo; @@ -53,34 +54,34 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class); * Insert a phone_ranges row correctly, creating a phone_ranges_imports record first. * ImitationTestCase::seedPhoneRange() uses wrong column names (FINDING F1). * - * @param int $defCode e.g. 999 - * @param int $from e.g. 5550000 - * @param int $to e.g. 5559999 + * @param int $defCode e.g. 999 + * @param int $from e.g. 5550000 + * @param int $to e.g. 5559999 * @param int $subjectCode ordinal 1..89 */ function insertPhoneRange(int $defCode, int $from, int $to, int $subjectCode): void { // Ensure a phone_ranges_imports anchor row exists. $importId = DB::table('phone_ranges_imports')->insertGetId([ - 'imported_at' => now(), - 'source_url' => 'test://rossvyaz', - 'rows_inserted' => 1, - 'rows_updated' => 0, + 'imported_at' => now(), + 'source_url' => 'test://rossvyaz', + 'rows_inserted' => 1, + 'rows_updated' => 0, 'checksum_sha256' => hash('sha256', "test-{$defCode}-{$from}-{$to}-{$subjectCode}"), - 'status' => 'completed', - 'completed_at' => now(), + 'status' => 'completed', + 'completed_at' => now(), ]); DB::table('phone_ranges')->insert([ - 'def_code' => $defCode, - 'from_num' => $from, - 'to_num' => $to, - 'operator' => 'test-operator', - 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', - 'region_normalized'=> null, - 'subject_code' => $subjectCode, - 'imported_at' => now(), - 'import_id' => $importId, + 'def_code' => $defCode, + 'from_num' => $from, + 'to_num' => $to, + 'operator' => 'test-operator', + 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', + 'region_normalized' => null, + 'subject_code' => $subjectCode, + 'imported_at' => now(), + 'import_id' => $importId, ]); } @@ -93,8 +94,8 @@ function makeLeadWithPhone(string $phone, string $tag = ''): SupplierLead return SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => $phone, - 'raw_payload' => ['tag' => $tag], + 'phone' => $phone, + 'raw_payload' => ['tag' => $tag], ]); } @@ -118,7 +119,7 @@ it('flag services.dadata.enabled=false falls through to tag-fallback (empty tag config(['services.dadata.enabled' => false]); $lead = makeLeadWithPhone('79990000001', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() @@ -130,7 +131,7 @@ it('flag services.dadata.enabled=false with a valid tag resolves to source=tag', config(['services.dadata.enabled' => false]); $lead = makeLeadWithPhone('79990000002', tag: 'Москва'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('tag') ->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']); @@ -146,11 +147,11 @@ it('qc=0 + region Москва (unambiguous, maps to subject_code 82) → source config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС'); + $fake = (new FakeDaDataPhoneClient)->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000010'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('dadata') ->and($res->subjectCode)->toBe($moscowCode) @@ -168,11 +169,11 @@ it('qc=0 + ambiguous region (Санкт-Петербург и область) fa $spbCode = RussianRegions::nameToCode()['Санкт-Петербург']; insertPhoneRange(defCode: 999, from: 6660000, to: 6669999, subjectCode: $spbCode); - $fake = (new FakeDaDataPhoneClient())->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон'); + $fake = (new FakeDaDataPhoneClient)->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79996660020'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() @@ -191,11 +192,11 @@ it('qc=1 + phone inside seeded phone_ranges range → source=rossvyaz, rossvyazM insertPhoneRange(defCode: 999, from: 5550000, to: 5559999, subjectCode: $tyumenCode); - $fake = (new FakeDaDataPhoneClient())->stub('79995550011', qc: 1); + $fake = (new FakeDaDataPhoneClient)->stub('79995550011', qc: 1); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79995550011'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() @@ -212,11 +213,11 @@ it('qc=2 with empty tag → source=unknown immediately (no rossvyaz)', function // The key invariant IS correct: Россвязь is NOT called for qc=2 (mусор). config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000020', qc: 2); + $fake = (new FakeDaDataPhoneClient)->stub('79990000020', qc: 2); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000020', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); // qc=2 → tagFallback → empty tag → source='unknown' (NOT 'tag') expect($res->source)->toBe('unknown') @@ -228,11 +229,11 @@ it('qc=2 with valid tag → source=tag (Россвязь skipped)', function (): // When qc=2 but tag resolves to a region, source='tag' (still no Россвязь). config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000021', qc: 2); + $fake = (new FakeDaDataPhoneClient)->stub('79990000021', qc: 2); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000021', tag: 'Москва'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('tag') ->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']) @@ -250,11 +251,11 @@ it('DaDataException (degradation) falls through to rossvyaz when range seeded', $voronezCode = RussianRegions::nameToCode()['Воронежская область']; // code 42 insertPhoneRange(defCode: 999, from: 7770000, to: 7779999, subjectCode: $voronezCode); - $fake = (new FakeDaDataPhoneClient())->stubThrows('79997770030'); + $fake = (new FakeDaDataPhoneClient)->stubThrows('79997770030'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79997770030'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() @@ -265,11 +266,11 @@ it('DaDataException with no rossvyaz range falls through to tag-fallback', funct // No phone_ranges seeded → rossvyaz returns null → tagFallback. config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stubThrows('79998880040'); + $fake = (new FakeDaDataPhoneClient)->stubThrows('79998880040'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79998880040', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() @@ -284,14 +285,14 @@ it('cache hit: resolving the same phone twice returns cacheHit=true on second ca config(['services.dadata.enabled' => true]); $phone = '79990000050'; - $fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); - $sp = SupplierProject::factory()->create(); + $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => $phone, - 'raw_payload' => ['tag' => ''], + 'phone' => $phone, + 'raw_payload' => ['tag' => ''], ]); // First resolution — populates cache; cacheHit must be false. @@ -302,8 +303,8 @@ it('cache hit: resolving the same phone twice returns cacheHit=true on second ca // Second lead with the SAME phone (different row, same cache key). $lead2 = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => $phone, - 'raw_payload' => ['tag' => ''], + 'phone' => $phone, + 'raw_payload' => ['tag' => ''], ]); // Second resolution — must come from cache; DaData NOT called again. @@ -333,36 +334,36 @@ it('RouteSupplierLeadJob persists resolved_subject_code/region_source/dadata_qc/ // Seed pricing tiers so LedgerService doesn't crash on boot. try { - $seeder = new \Database\Seeders\PricingTierSeeder(); + $seeder = new PricingTierSeeder; $seeder->run(); - } catch (\Throwable) { + } catch (Throwable) { // Already seeded or not required for this path. } $phone = '79990000060'; $moscowCode = RussianRegions::nameToCode()['Москва']; - $fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); - $sp = SupplierProject::factory()->create(); + $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => $phone, - 'raw_payload' => [ - 'tag' => '', + 'phone' => $phone, + 'raw_payload' => [ + 'tag' => '', 'project' => 'B1_test.example.com', - 'time' => now()->getTimestamp(), - 'vid' => 123456789, + 'time' => now()->getTimestamp(), + 'vid' => 123456789, ], - 'vid' => 123456789, + 'vid' => 123456789, 'processed_at' => null, ]); // Dispatch the job synchronously. It will run the full handle() path. // LeadRouter::matchEligibleProjects() will return empty (no snapshot seeded) → 0 deals created. // The resolver + persistence UPDATE still executes before the routing loop. - \App\Jobs\RouteSupplierLeadJob::dispatchSync($lead->id); + RouteSupplierLeadJob::dispatchSync($lead->id); $lead->refresh(); diff --git a/app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php b/app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php index 668736d7..02e94b1f 100644 --- a/app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php +++ b/app/tests/Feature/Imitation/ScenarioA_WeightedLotteryTest.php @@ -106,9 +106,9 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu // One shared supplier project (B1 site signal). $supplier = SupplierProject::factory()->create([ - 'platform' => SUPPLIER_PLATFORM, + 'platform' => SUPPLIER_PLATFORM, 'signal_type' => 'site', - 'unique_key' => SUPPLIER_DOMAIN, + 'unique_key' => SUPPLIER_DOMAIN, ]); // Create 5 tenants + projects, one per limit tier. @@ -118,24 +118,24 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu foreach ($limits as $idx => $limit) { $tenant = Tenant::factory()->create([ - 'balance_rub' => '99999.00', // ample balance — billing must not block + 'balance_rub' => '99999.00', // ample balance — billing must not block 'frozen_by_balance_at' => null, ]); $tenants[] = $tenant; $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => SUPPLIER_DOMAIN, - 'daily_limit_target' => $limit, + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => SUPPLIER_DOMAIN, + 'daily_limit_target' => $limit, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, // all days - 'preflight_blocked_at' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, // all days + 'preflight_blocked_at' => null, // PostgresIntArray cast: pass PHP array, cast serialises to '{82}'. - 'regions' => [MOSCOW_SUBJECT_CODE], + 'regions' => [MOSCOW_SUBJECT_CODE], ]); // Link project to supplier. @@ -156,32 +156,32 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu signalType: 'site', signalIdentifier: SUPPLIER_DOMAIN, dailyLimit: $limits[$idx], - regions: '{' . MOSCOW_SUBJECT_CODE . '}', + regions: '{'.MOSCOW_SUBJECT_CODE.'}', ); } // Build a FakeDaDataPhoneClient that maps all test phones to Москва (qc=0). // We generate deterministic phone numbers: 7(916)000XXXX where XXXX = index 0001..0300. - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; for ($i = 1; $i <= LEAD_COUNT; $i++) { - $phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT); + $phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT); $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); } app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); - $vidBase = 9_000_000_000; // high range, outside real supplier VIDs + $injector = new LeadInjector; + $vidBase = 9_000_000_000; // high range, outside real supplier VIDs for ($i = 1; $i <= LEAD_COUNT; $i++) { - $phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT); + $phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT); $injector->site( - domain: SUPPLIER_DOMAIN, - phone: $phone, - tag: 'Москва', + domain: SUPPLIER_DOMAIN, + phone: $phone, + tag: 'Москва', platform: SUPPLIER_PLATFORM, - vid: $vidBase + $i, + vid: $vidBase + $i, ); } @@ -202,12 +202,12 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu // ── REPORT ────────────────────────────────────────────────────────────────── // Print observed distribution for the required report. - fwrite(STDOUT, PHP_EOL . '=== SCENARIO A DISTRIBUTION REPORT ===' . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== SCENARIO A DISTRIBUTION REPORT ==='.PHP_EOL); fwrite(STDOUT, sprintf('Total leads injected: %d%s', LEAD_COUNT, PHP_EOL)); fwrite(STDOUT, sprintf('Total deals created: %d (≤ %d × cap=3 = %d)%s', $totalDeals, LEAD_COUNT, LEAD_COUNT * 3, PHP_EOL)); - fwrite(STDOUT, PHP_EOL . sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL)); - fwrite(STDOUT, str_repeat('-', 45) . PHP_EOL); + fwrite(STDOUT, PHP_EOL.sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL)); + fwrite(STDOUT, str_repeat('-', 45).PHP_EOL); foreach ($projects as $idx => $project) { $deals = $dealCounts[$idx]; $share = $totalDeals > 0 ? round($deals / $totalDeals * 100, 1) : 0.0; @@ -220,7 +220,7 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu PHP_EOL )); } - fwrite(STDOUT, '=== END DISTRIBUTION ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, '=== END DISTRIBUTION ==='.PHP_EOL.PHP_EOL); // ── ASSERTIONS ─────────────────────────────────────────────────────────────── @@ -228,12 +228,12 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu // After the two limit-3 projects hit their cap, P0 competes with two limit-30 // projects; but even before that, its weight (300) vastly outweighs theirs. $bigProjectDeals = $dealCounts[0]; // limit=300 - $maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2 + $maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2 expect($bigProjectDeals)->toBeGreaterThan($maxSmallDeals, - "FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. " . - "Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. " . - "This would indicate the weighted lottery is not proportional." + 'FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. '. + "Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. ". + 'This would indicate the weighted lottery is not proportional.' ); // (b) Both smallest projects (limit=3) received > 0 deals. @@ -241,13 +241,13 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu // They CAN only receive at most 3 deals each (their limit), so they'll // hit their limit early and then drop out of eligibility. expect($dealCounts[3])->toBeGreaterThan(0, - "FINDING: small-limit project P3 (limit=3) received 0 deals. " . - "The spec guarantees weight≥1 so small clients are NOT cut off. " . + 'FINDING: small-limit project P3 (limit=3) received 0 deals. '. + 'The spec guarantees weight≥1 so small clients are NOT cut off. '. "Actual: P3={$dealCounts[3]}. This is a bug." ); expect($dealCounts[4])->toBeGreaterThan(0, - "FINDING: small-limit project P4 (limit=3) received 0 deals. " . - "The spec guarantees weight≥1 so small clients are NOT cut off. " . + 'FINDING: small-limit project P4 (limit=3) received 0 deals. '. + 'The spec guarantees weight≥1 so small clients are NOT cut off. '. "Actual: P4={$dealCounts[4]}. This is a bug." ); @@ -262,21 +262,21 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu $p0Share = $totalDeals > 0 ? $bigProjectDeals / $totalDeals : 0.0; expect($p0Share)->toBeGreaterThan(0.45, - "FINDING: big-limit project P0 (limit=300) has a share of " . - round($p0Share * 100, 1) . "% which is below 45%. " . - "Expected substantially more than limit-30 projects given weights 300 vs 30. " . - "Limits: " . json_encode($limits) . ", Deals: " . json_encode($dealCounts) + 'FINDING: big-limit project P0 (limit=300) has a share of '. + round($p0Share * 100, 1).'% which is below 45%. '. + 'Expected substantially more than limit-30 projects given weights 300 vs 30. '. + 'Limits: '.json_encode($limits).', Deals: '.json_encode($dealCounts) ); // (d) Small projects each received exactly their limit (3) or fewer. // They drop out once delivered_today == daily_limit, so max is 3. expect($dealCounts[3])->toBeLessThanOrEqual(3, - "FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. " . - "The eligibility guard (delivered_today < daily_limit) should prevent this." + "FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. ". + 'The eligibility guard (delivered_today < daily_limit) should prevent this.' ); expect($dealCounts[4])->toBeLessThanOrEqual(3, - "FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. " . - "The eligibility guard (delivered_today < daily_limit) should prevent this." + "FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. ". + 'The eligibility guard (delivered_today < daily_limit) should prevent this.' ); })->group('imitation'); diff --git a/app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php b/app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php index 1a11efd8..cc00eff3 100644 --- a/app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php +++ b/app/tests/Feature/Imitation/ScenarioBC_RegionCascadeTest.php @@ -116,84 +116,84 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => BC_B_DOMAIN, + 'unique_key' => BC_B_DOMAIN, ]); // Client X: only Москва (exact match for subject=82). $tenantX = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectX = Project::factory()->create([ - 'tenant_id' => $tenantX->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_B_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantX->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_B_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_MOSCOW], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_MOSCOW], ]); linkProjectToSupplier($projectX, $supplier); // Client Y: all-RF (regions='{}'). $tenantY = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectY = Project::factory()->create([ - 'tenant_id' => $tenantY->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_B_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantY->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_B_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [], // all-RF + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [], // all-RF ]); linkProjectToSupplier($projectY, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $projectX, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_B_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_MOSCOW . '}', + project: $projectX, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_B_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_MOSCOW.'}', ); createRoutingSnapshotFromProject( - project: $projectY, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_B_DOMAIN, - dailyLimit: 10, - regions: '{}', + project: $projectY, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_B_DOMAIN, + dailyLimit: 10, + regions: '{}', ); // Lead resolves to Москва via FakeDaData (qc=0 → source=dadata, subject=82). - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79161000001', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: BC_B_DOMAIN, - phone: '79161000001', - tag: 'Москва', + domain: BC_B_DOMAIN, + phone: '79161000001', + tag: 'Москва', platform: 'B1', - vid: 8_100_000_001, + vid: 8_100_000_001, ); // ── ASSERT ────────────────────────────────────────────────────────────────── @@ -210,13 +210,13 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste ->where('tenant_id', $tenantY->id) ->count(); - fwrite(STDOUT, PHP_EOL . '=== B1 DISTRIBUTION ===' . PHP_EOL); - fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL); - fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== B1 DISTRIBUTION ==='.PHP_EOL); + fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL); + fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL); // Primary assertion: X gets the lead (routing_step=1 path exists). expect($dealsX)->toBe(1, - "FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. " . + 'FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. '. "Got dealsX={$dealsX}. The exact-match phase (step 1) may not be working." ); @@ -226,24 +226,24 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste ->where('supplier_lead_id', $lead->id) ->first(); - fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, "resolution_log region_source: " . ($logRow?->region_source ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, '=== END B1 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'resolution_log region_source: '.($logRow?->region_source ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); + fwrite(STDOUT, '=== END B1 ==='.PHP_EOL.PHP_EOL); expect($logRow)->not->toBeNull( - "FINDING: lead_region_resolution_log has no row for this lead. " . - "logRegionResolution() may have failed silently (fail-safe)." + 'FINDING: lead_region_resolution_log has no row for this lead. '. + 'logRegionResolution() may have failed silently (fail-safe).' ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(1, - "FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). " . + 'FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). '. "Got: {$logRow->routing_step}. The log records step of first project in selected collection." ); expect((int) $logRow->subject_code_resolved)->toBe(BC_MOSCOW, - "FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. " . + 'FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. '. "Got: {$logRow->subject_code_resolved}." ); } @@ -256,13 +256,12 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste if ($dealX !== null) { expect((int) $dealX->subject_code)->toBe(BC_MOSCOW, - "FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. " . + 'FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. '. "Got: {$dealX->subject_code}." ); } })->group('imitation'); - /** * Scenario B2: Lead with a subject nobody has exactly → goes to all-RF client (step 2). * @@ -281,86 +280,86 @@ it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody ha // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => BC_B_DOMAIN, + 'unique_key' => BC_B_DOMAIN, ]); // Client X: regions=[82] (Москва) — will NOT match code 50. $tenantX = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectX = Project::factory()->create([ - 'tenant_id' => $tenantX->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_B_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantX->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_B_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_MOSCOW], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_MOSCOW], ]); linkProjectToSupplier($projectX, $supplier); // Client Y: all-RF — will match any subject at phase 2. $tenantY = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectY = Project::factory()->create([ - 'tenant_id' => $tenantY->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_B_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantY->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_B_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [], // all-RF + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [], // all-RF ]); linkProjectToSupplier($projectY, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $projectX, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_B_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_MOSCOW . '}', + project: $projectX, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_B_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_MOSCOW.'}', ); createRoutingSnapshotFromProject( - project: $projectY, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_B_DOMAIN, - dailyLimit: 10, - regions: '{}', + project: $projectY, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_B_DOMAIN, + dailyLimit: 10, + regions: '{}', ); // Lead resolves to Костромская область (code 50) — DaData qc=0, region name must // be the exact string in RussianRegions::CODE_TO_NAME[50] = 'Костромская область' // so that DaDataRegionMap::toSubjectCode() returns 50. - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79162000001', qc: 0, region: 'Костромская область', provider: 'Билайн'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: BC_B_DOMAIN, - phone: '79162000001', - tag: null, + domain: BC_B_DOMAIN, + phone: '79162000001', + tag: null, platform: 'B1', - vid: 8_100_000_002, + vid: 8_100_000_002, ); // ── ASSERT ────────────────────────────────────────────────────────────────── @@ -380,43 +379,42 @@ it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody ha ->where('supplier_lead_id', $lead->id) ->first(); - fwrite(STDOUT, PHP_EOL . '=== B2 DISTRIBUTION ===' . PHP_EOL); - fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL); - fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL); - fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, '=== END B2 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== B2 DISTRIBUTION ==='.PHP_EOL); + fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL); + fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL); + fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); + fwrite(STDOUT, '=== END B2 ==='.PHP_EOL.PHP_EOL); // X must NOT receive the lead (code 50 is NOT in X's regions=[82]). expect($dealsX)->toBe(0, - "FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). " . + 'FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). '. "Got dealsX={$dealsX}. Phase-1 exact filter may be matching wrong subjects." ); // Y must receive the lead (all-RF, step 2). expect($dealsY)->toBe(1, - "FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. " . + "FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. ". "Got dealsY={$dealsY}. Phase-2 all-RF filter may not be working." ); expect($logRow)->not->toBeNull( - "FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed." + 'FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed.' ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(2, - "FINDING: routing_step should be 2 (first project in selected is Y at step 2). " . + 'FINDING: routing_step should be 2 (first project in selected is Y at step 2). '. "Got: {$logRow->routing_step}." ); expect((int) $logRow->subject_code_resolved)->toBe(BC_FOREIGN, - "FINDING: resolved subject_code should be 50 (Костромская область). " . + 'FINDING: resolved subject_code should be 50 (Костромская область). '. "Got: {$logRow->subject_code_resolved}." ); } })->group('imitation'); - // ══════════════════════════════════════════════════════════════════════════════ // SCENARIO C — each client gets only its own region (phase-1 isolation) // ══════════════════════════════════════════════════════════════════════════════ @@ -453,85 +451,85 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => BC_C_DOMAIN, + 'unique_key' => BC_C_DOMAIN, ]); // Client A: Республика Адыгея (code 1). $tenantA = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectA = Project::factory()->create([ - 'tenant_id' => $tenantA->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_C_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantA->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_C_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_ADYGEA], // code 1 + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_ADYGEA], // code 1 ]); linkProjectToSupplier($projectA, $supplier); // Client B: Санкт-Петербург (code 83). $tenantB = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectB = Project::factory()->create([ - 'tenant_id' => $tenantB->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_C_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantB->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_C_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_SPB], // code 83 + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_SPB], // code 83 ]); linkProjectToSupplier($projectB, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $projectA, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_C_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_ADYGEA . '}', + project: $projectA, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_C_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_ADYGEA.'}', ); createRoutingSnapshotFromProject( - project: $projectB, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_C_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_SPB . '}', + project: $projectB, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_C_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_SPB.'}', ); // FakeDaData: phone→Adygea (code 1). // RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея' - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79163000001', qc: 0, region: 'Республика Адыгея', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $leadAdygea = $injector->site( - domain: BC_C_DOMAIN, - phone: '79163000001', - tag: null, + domain: BC_C_DOMAIN, + phone: '79163000001', + tag: null, platform: 'B1', - vid: 8_100_000_010, + vid: 8_100_000_010, ); // ── ASSERT ────────────────────────────────────────────────────────────────── @@ -551,27 +549,27 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli ->where('supplier_lead_id', $leadAdygea->id) ->first(); - fwrite(STDOUT, PHP_EOL . '=== C1 DISTRIBUTION (lead→Адыгея) ===' . PHP_EOL); - fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL); - fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL); - fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, '=== END C1 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== C1 DISTRIBUTION (lead→Адыгея) ==='.PHP_EOL); + fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL); + fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL); + fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); + fwrite(STDOUT, '=== END C1 ==='.PHP_EOL.PHP_EOL); // A must receive the lead. expect($dealsA)->toBe(1, - "FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. " . + 'FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. '. "Got dealsA={$dealsA}. Phase-1 exact match may not be working for small subject codes." ); // B must NOT receive the lead (step-1 only → combined=[A] is not empty → phase 3 skipped). expect($dealsB)->toBe(0, - "FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. " . - "Got dealsB={$dealsB}. " . - "If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. " . - "This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, " . - "so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. " . - "If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch." + 'FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. '. + "Got dealsB={$dealsB}. ". + "If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. ". + 'This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, '. + 'so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. '. + 'If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch.' ); if ($logRow !== null) { @@ -581,7 +579,6 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli } })->group('imitation'); - /** * Scenario C2: Lead with СПб subject goes only to client B, not to A. * @@ -591,83 +588,83 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => BC_C_DOMAIN, + 'unique_key' => BC_C_DOMAIN, ]); $tenantA = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectA = Project::factory()->create([ - 'tenant_id' => $tenantA->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_C_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantA->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_C_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_ADYGEA], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_ADYGEA], ]); linkProjectToSupplier($projectA, $supplier); $tenantB = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectB = Project::factory()->create([ - 'tenant_id' => $tenantB->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => BC_C_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantB->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => BC_C_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [BC_SPB], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [BC_SPB], ]); linkProjectToSupplier($projectB, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $projectA, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_C_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_ADYGEA . '}', + project: $projectA, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_C_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_ADYGEA.'}', ); createRoutingSnapshotFromProject( - project: $projectB, - date: $activeDate, - signalType: 'site', - signalIdentifier: BC_C_DOMAIN, - dailyLimit: 10, - regions: '{' . BC_SPB . '}', + project: $projectB, + date: $activeDate, + signalType: 'site', + signalIdentifier: BC_C_DOMAIN, + dailyLimit: 10, + regions: '{'.BC_SPB.'}', ); // FakeDaData: phone→СПб (code 83). // RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург' - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79164000001', qc: 0, region: 'Санкт-Петербург', provider: 'Теле2'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $leadSpb = $injector->site( - domain: BC_C_DOMAIN, - phone: '79164000001', - tag: null, + domain: BC_C_DOMAIN, + phone: '79164000001', + tag: null, platform: 'B1', - vid: 8_100_000_011, + vid: 8_100_000_011, ); // ── ASSERT ────────────────────────────────────────────────────────────────── @@ -687,22 +684,22 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c ->where('supplier_lead_id', $leadSpb->id) ->first(); - fwrite(STDOUT, PHP_EOL . '=== C2 DISTRIBUTION (lead→СПб) ===' . PHP_EOL); - fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL); - fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL); - fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, '=== END C2 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== C2 DISTRIBUTION (lead→СПб) ==='.PHP_EOL); + fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL); + fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL); + fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); + fwrite(STDOUT, '=== END C2 ==='.PHP_EOL.PHP_EOL); // B must receive the lead (exact СПб match at step 1). expect($dealsB)->toBe(1, - "FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. " . + 'FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. '. "Got dealsB={$dealsB}." ); // A must NOT receive the lead. expect($dealsA)->toBe(0, - "FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. " . + 'FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. '. "Got dealsA={$dealsA}. Phase-3 fallback may have fired — investigate." ); @@ -712,7 +709,7 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c ); expect((int) $logRow->subject_code_resolved)->toBe(BC_SPB, - "FINDING: resolved subject_code should be 83 (Санкт-Петербург). " . + 'FINDING: resolved subject_code should be 83 (Санкт-Петербург). '. "Got: {$logRow->subject_code_resolved}." ); } diff --git a/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php b/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php index 3ecb3a9d..c15a8126 100644 --- a/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php +++ b/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php @@ -15,7 +15,6 @@ use Illuminate\Support\Facades\DB; use Random\Engine\Mt19937; use Random\Randomizer; use Tests\Concerns\SharesSupplierPdo; -use Tests\Support\Imitation\ConditionLevers; use Tests\Support\Imitation\FakeDaDataPhoneClient; use Tests\Support\Imitation\LeadInjector; use Tests\Support\Imitation\SnapshotForge; @@ -69,7 +68,7 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class); const D_MOSCOW_CODE = 82; // ── SUPPLIER SIGNAL ─────────────────────────────────────────────────────────── -const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru'; +const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru'; const D_SUPPLIER_PLATFORM = 'B1'; // ── DETERMINISTIC SEED ──────────────────────────────────────────────────────── @@ -88,9 +87,9 @@ beforeEach(function (): void { // DaData config — FakeDaDataPhoneClient bypasses HTTP entirely. config([ - 'services.dadata.enabled' => true, - 'services.dadata.api_key' => 'fake-key', - 'services.dadata.secret' => 'fake-secret', + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); @@ -108,9 +107,9 @@ it('only delivers to the active-today client; the excluded-day client gets zero // // We MUST compute the bit from the active-snapshot date (not wall-clock today), // because after 21:00 MSK the active date flips to tomorrow. - $activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD' + $activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD' $activeDateObj = Carbon::parse($activeDate, 'Europe/Moscow'); - $todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4 + $todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4 // ACTIVE mask: full week (includes every day, including today). $activeMask = 127; // 0b1111111 @@ -120,56 +119,56 @@ it('only delivers to the active-today client; the excluded-day client gets zero $inactiveMask = 127 & ~$todayBit; // clears the bit for the active date's weekday // Sanity: active mask passes the filter, inactive mask does not. - expect(($activeMask & $todayBit) !== 0)->toBeTrue(); + expect(($activeMask & $todayBit) !== 0)->toBeTrue(); expect(($inactiveMask & $todayBit))->toBe(0); // ── ARRANGE: SHARED SUPPLIER PROJECT ───────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => D_SUPPLIER_PLATFORM, + 'platform' => D_SUPPLIER_PLATFORM, 'signal_type' => 'site', - 'unique_key' => D_SUPPLIER_DOMAIN, + 'unique_key' => D_SUPPLIER_DOMAIN, ]); // ── ARRANGE: ACTIVE TENANT / PROJECT ───────────────────────────────────── $tenantActive = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $projectActive = Project::factory()->create([ - 'tenant_id' => $tenantActive->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => D_SUPPLIER_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantActive->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => D_SUPPLIER_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => $activeMask, // all days — included today - 'preflight_blocked_at' => null, - 'regions' => [D_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => $activeMask, // all days — included today + 'preflight_blocked_at' => null, + 'regions' => [D_MOSCOW_CODE], ]); linkProjectToSupplier($projectActive, $supplier); // ── ARRANGE: INACTIVE TENANT / PROJECT ─────────────────────────────────── $tenantInactive = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $projectInactive = Project::factory()->create([ - 'tenant_id' => $tenantInactive->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => D_SUPPLIER_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenantInactive->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => D_SUPPLIER_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot - 'preflight_blocked_at' => null, - 'regions' => [D_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot + 'preflight_blocked_at' => null, + 'regions' => [D_MOSCOW_CODE], ]); linkProjectToSupplier($projectInactive, $supplier); @@ -194,29 +193,29 @@ it('only delivers to the active-today client; the excluded-day client gets zero ->exists(); expect($snapshotActiveExists)->toBeTrue( - "FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) " . - "was expected in the snapshot for {$activeDate} but is absent. " . - "SnapshotRebuildCommand may have a bug in delivery_days_mask filtering." + "FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) ". + "was expected in the snapshot for {$activeDate} but is absent. ". + 'SnapshotRebuildCommand may have a bug in delivery_days_mask filtering.' ); expect($snapshotInactiveExists)->toBeFalse( - "FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) " . - "was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. " . - "This means the delivery_days_mask filter is not working — the inactive project " . - "will receive leads it should not receive." + "FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) ". + "was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. ". + 'This means the delivery_days_mask filter is not working — the inactive project '. + 'will receive leads it should not receive.' ); // ── ARRANGE: FAKE DADATA CLIENT ────────────────────────────────────────── - $fake = (new FakeDaDataPhoneClient())->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС'); + $fake = (new FakeDaDataPhoneClient)->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); // ── ACT: INJECT ONE LEAD ───────────────────────────────────────────────── - (new LeadInjector())->site( - domain: D_SUPPLIER_DOMAIN, - phone: D_PHONE_1, - tag: 'Москва', + (new LeadInjector)->site( + domain: D_SUPPLIER_DOMAIN, + phone: D_PHONE_1, + tag: 'Москва', platform: D_SUPPLIER_PLATFORM, - vid: 88_000_000_001, + vid: 88_000_000_001, ); // ── ASSERT: DEALS DISTRIBUTION ─────────────────────────────────────────── @@ -234,7 +233,7 @@ it('only delivers to the active-today client; the excluded-day client gets zero ->count(); // Diagnostic output. - fwrite(STDOUT, PHP_EOL . '=== SCENARIO D: DELIVERY DAYS FILTER REPORT ===' . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== SCENARIO D: DELIVERY DAYS FILTER REPORT ==='.PHP_EOL); fwrite(STDOUT, sprintf('Active date: %s (isoWeekday=%d)%s', $activeDate, $activeDateObj->isoWeekday(), PHP_EOL)); fwrite(STDOUT, sprintf('Today bit: %d (0b%s)%s', @@ -247,18 +246,18 @@ it('only delivers to the active-today client; the excluded-day client gets zero $snapshotInactiveExists ? 'PRESENT (BUG!)' : 'ABSENT (correct)', PHP_EOL)); fwrite(STDOUT, sprintf('Active client deals: %d (expected: 1)%s', $activeDeals, PHP_EOL)); fwrite(STDOUT, sprintf('Inactive client deals: %d (expected: 0)%s', $inactiveDeals, PHP_EOL)); - fwrite(STDOUT, '=== END SCENARIO D ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, '=== END SCENARIO D ==='.PHP_EOL.PHP_EOL); expect($activeDeals)->toBe(1, - "FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) " . - "expected 1 deal but got {$activeDeals}. " . - "Check LeadRouter eligibility query or LedgerService::chargeForDelivery." + "FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) ". + "expected 1 deal but got {$activeDeals}. ". + 'Check LeadRouter eligibility query or LedgerService::chargeForDelivery.' ); expect($inactiveDeals)->toBe(0, - "FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, " . - "project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. " . - "The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. " . + "FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, ". + "project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. ". + 'The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. '. "This is a correctness bug: clients with today's bit cleared MUST be absent from the snapshot." ); diff --git a/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php b/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php index 8bb83b40..c7c6ffe2 100644 --- a/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php +++ b/app/tests/Feature/Imitation/ScenarioEF_FreezeLimitTest.php @@ -19,6 +19,7 @@ use Tests\Concerns\SharesSupplierPdo; use Tests\Support\Imitation\ConditionLevers; use Tests\Support\Imitation\FakeDaDataPhoneClient; use Tests\Support\Imitation\LeadInjector; +use Tests\Support\Imitation\SnapshotForge; uses(DatabaseTransactions::class, SharesSupplierPdo::class); @@ -78,9 +79,9 @@ beforeEach(function (): void { // DaData config — required by LeadRegionResolver even when FakeDaDataPhoneClient is used. config([ - 'services.dadata.enabled' => true, - 'services.dadata.api_key' => 'fake-key', - 'services.dadata.secret' => 'fake-secret', + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); @@ -99,49 +100,49 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes // One shared supplier project (B1 site signal). $supplier = SupplierProject::factory()->create([ - 'platform' => EF_PLATFORM, + 'platform' => EF_PLATFORM, 'signal_type' => 'site', - 'unique_key' => EF_DOMAIN, + 'unique_key' => EF_DOMAIN, ]); // Client1: balance BELOW tier-1 price (500 RUB). Even 1 kopeck short triggers pause. // We set balance to 0 which is definitely below 500 RUB threshold. $tenant1 = Tenant::factory()->create([ - 'balance_rub' => '0.00', + 'balance_rub' => '0.00', 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ - 'tenant_id' => $tenant1->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); // Client2: healthy balance — should receive the lead. $tenant2 = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ - 'tenant_id' => $tenant2->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); @@ -158,7 +159,7 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes // Tier 1 = 500 RUB. Set to 499.99 — positive but insufficient. ConditionLevers::setBalance($tenant1, '499.99'); - $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + $activeDate = SnapshotForge::activeDate(); // Build snapshots for both clients so both pass the snapshot filter. createRoutingSnapshotFromProject( @@ -167,7 +168,7 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, @@ -175,24 +176,24 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); // FakeDaData: phone → Москва (qc=0, subject_code=82). $phone = '79161234001'; - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: EF_DOMAIN, - phone: $phone, - tag: 'Москва', + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', platform: EF_PLATFORM, - vid: 1_100_000_001, + vid: 1_100_000_001, ); // ── ASSERT ─────────────────────────────────────────────────────────────── @@ -200,9 +201,9 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes // Client1's project must be paused (is_active=false) after the balance failure. $project1->refresh(); expect($project1->is_active)->toBeFalse( - 'FINDING E1: project1 was NOT paused after InsufficientBalance. ' . - 'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. ' . - 'Actual is_active=' . ($project1->is_active ? 'true' : 'false') + 'FINDING E1: project1 was NOT paused after InsufficientBalance. '. + 'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. '. + 'Actual is_active='.($project1->is_active ? 'true' : 'false') ); // ZeroBalancePausedMail must have been sent for tenant1 (rate-limit: first call always fires). @@ -217,8 +218,8 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes ->count(); expect($tenant2Deals)->toBe(1, - 'FINDING E1: Client2 (healthy) did NOT receive the lead. ' . - "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'FINDING E1: Client2 (healthy) did NOT receive the lead. '. + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'The auto-pause flow should skip Client1 and continue routing to Client2.' ); @@ -229,8 +230,8 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes ->count(); expect($tenant1Deals)->toBe(0, - 'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. ' . - "Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. " . + 'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. '. + "Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. ". 'InsufficientBalance should roll back the transaction, preventing deal creation.' ); @@ -248,30 +249,30 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead // ── ARRANGE ────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => EF_PLATFORM, + 'platform' => EF_PLATFORM, 'signal_type' => 'site', - 'unique_key' => EF_DOMAIN, + 'unique_key' => EF_DOMAIN, ]); // Client1: frozen via ConditionLevers::freeze(). // After freeze(), frozen_by_balance_at IS NOT NULL → excluded by LeadRouter SQL // WHERE tenants.frozen_by_balance_at IS NULL. $tenant1 = Tenant::factory()->create([ - 'balance_rub' => '9999.00', // balance is fine — frozen is the blocker + 'balance_rub' => '9999.00', // balance is fine — frozen is the blocker 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ - 'tenant_id' => $tenant1->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); @@ -282,25 +283,25 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead // Client2: healthy. $tenant2 = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ - 'tenant_id' => $tenant2->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); - $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + $activeDate = SnapshotForge::activeDate(); // Snapshots for both clients. The snapshot itself may include Client1 (snapshot was // built from static project data), but LeadRouter's SQL live-checks frozen_by_balance_at. @@ -310,7 +311,7 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, @@ -318,23 +319,23 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); $phone = '79161234002'; - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: EF_DOMAIN, - phone: $phone, - tag: 'Москва', + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', platform: EF_PLATFORM, - vid: 1_100_000_002, + vid: 1_100_000_002, ); // ── ASSERT ─────────────────────────────────────────────────────────────── @@ -346,9 +347,9 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead ->count(); expect($tenant1Deals)->toBe(0, - 'FINDING E2: Frozen client (tenant1) received a deal. ' . - "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " . - 'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. ' . + 'FINDING E2: Frozen client (tenant1) received a deal. '. + "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ". + 'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. '. 'This is a serious billing/freeze bug.' ); @@ -359,15 +360,15 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead ->count(); expect($tenant2Deals)->toBe(1, - 'FINDING E2: Healthy Client2 did NOT receive the lead. ' . - "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'FINDING E2: Healthy Client2 did NOT receive the lead. '. + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'With frozen Client1 excluded, Client2 should be the sole eligible recipient.' ); // Verify tenant1 IS still frozen (no accidental unfreeze by any path). $tenant1->refresh(); expect($tenant1->frozen_by_balance_at)->not->toBeNull( - 'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. ' . + 'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. '. 'Freeze state must be preserved across the routing cycle.' ); @@ -385,54 +386,54 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap // ── ARRANGE ────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ - 'platform' => EF_PLATFORM, + 'platform' => EF_PLATFORM, 'signal_type' => 'site', - 'unique_key' => EF_DOMAIN, + 'unique_key' => EF_DOMAIN, ]); $dailyLimit = 5; // small limit so fillToLimit is clear // Client1: delivered_today EQUAL to daily_limit → excluded (delivered_today < daily_limit is false). $tenant1 = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project1 = Project::factory()->create([ - 'tenant_id' => $tenant1->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => $dailyLimit, + 'tenant_id' => $tenant1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => $dailyLimit, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project1, $supplier); // Client2: healthy with headroom. $tenant2 = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, ]); $project2 = Project::factory()->create([ - 'tenant_id' => $tenant2->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => EF_DOMAIN, - 'daily_limit_target' => EF_HEALTHY_LIMIT, + 'tenant_id' => $tenant2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => EF_DOMAIN, + 'daily_limit_target' => EF_HEALTHY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [EF_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [EF_MOSCOW_CODE], ]); linkProjectToSupplier($project2, $supplier); - $activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate(); + $activeDate = SnapshotForge::activeDate(); // Build snapshots BEFORE setting delivered_today to limit, // so both clients appear in the snapshot. @@ -442,7 +443,7 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: $dailyLimit, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); createRoutingSnapshotFromProject( project: $project2, @@ -450,7 +451,7 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap signalType: 'site', signalIdentifier: EF_DOMAIN, dailyLimit: EF_HEALTHY_LIMIT, - regions: '{' . EF_MOSCOW_CODE . '}', + regions: '{'.EF_MOSCOW_CODE.'}', ); // NOW set Client1 delivered_today = limit (5) so it fails the eligibility check: @@ -459,19 +460,19 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap ConditionLevers::fillToLimit($project1); $phone = '79161234003'; - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: EF_DOMAIN, - phone: $phone, - tag: 'Москва', + domain: EF_DOMAIN, + phone: $phone, + tag: 'Москва', platform: EF_PLATFORM, - vid: 1_100_000_003, + vid: 1_100_000_003, ); // ── ASSERT ─────────────────────────────────────────────────────────────── @@ -483,9 +484,9 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap ->count(); expect($tenant1Deals)->toBe(0, - 'FINDING F: Client1 (at daily limit) received a deal. ' . - "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " . - "LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. " . + 'FINDING F: Client1 (at daily limit) received a deal. '. + "Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ". + 'LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. '. "project1.daily_limit_target={$dailyLimit}, delivered_today should be {$dailyLimit} after fillToLimit." ); @@ -496,25 +497,25 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap ->count(); expect($tenant2Deals)->toBe(1, - 'FINDING F: Client2 (with headroom) did NOT receive the lead. ' . - "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " . + 'FINDING F: Client2 (with headroom) did NOT receive the lead. '. + "Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ". 'With Client1 at limit and excluded, Client2 should be the sole eligible recipient.' ); // Verify project1 delivered_today is still at limit (nothing was delivered to it). $project1->refresh(); expect((int) $project1->delivered_today)->toBe($dailyLimit, - 'FINDING F: project1.delivered_today changed during routing. ' . - "Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. " . + 'FINDING F: project1.delivered_today changed during routing. '. + "Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. ". 'A lead was delivered to a project that exceeded its limit.' ); // project1 is_active should still be true — limit exhaustion alone does NOT trigger // auto-pause (only InsufficientBalance does). This is a deliberate design check. expect($project1->is_active)->toBeTrue( - 'FINDING F: project1.is_active was set to false due to limit exhaustion. ' . - 'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. ' . - 'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). ' . + 'FINDING F: project1.is_active was set to false due to limit exhaustion. '. + 'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. '. + 'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). '. 'If this fails: auto-pause is being triggered by the wrong condition — report as bug.' ); diff --git a/app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php b/app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php index 5ff77531..31701679 100644 --- a/app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php +++ b/app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Models\Deal; use App\Models\Project; use App\Models\SupplierLead; use App\Models\SupplierProject; @@ -51,7 +50,6 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class); * - NO exception (job не упал); * - Непроданный лид виден в supplier_leads. */ - const G3_MOSCOW_CODE = 82; const G3_SUPPLIER_DOMAIN = 'scenario-g3-orphan.ru'; const G3_SUPPLIER_PLATFORM = 'B1'; @@ -70,7 +68,7 @@ beforeEach(function (): void { 'services.dadata.daily_cap_rub' => 1_000_000, ]); - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub(G3_LEAD_PHONE, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); }); @@ -80,9 +78,9 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move // One shared SupplierProject (B1 site signal). $supplier = SupplierProject::factory()->create([ - 'platform' => G3_SUPPLIER_PLATFORM, + 'platform' => G3_SUPPLIER_PLATFORM, 'signal_type' => 'site', - 'unique_key' => G3_SUPPLIER_DOMAIN, + 'unique_key' => G3_SUPPLIER_DOMAIN, ]); $activeDate = SnapshotForge::activeDate(); @@ -90,21 +88,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move // ── Client P1: limit exhausted — fillToLimit makes delivered_today = daily_limit_target. // queryCandidates: projects.delivered_today < snap.daily_limit → FALSE → excluded from all phases. $tenantP1 = Tenant::factory()->create([ - 'balance_rub' => '999.00', + 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP1 = Project::factory()->create([ - 'tenant_id' => $tenantP1->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => G3_SUPPLIER_DOMAIN, - 'daily_limit_target' => G3_DAILY_LIMIT, + 'tenant_id' => $tenantP1->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => G3_SUPPLIER_DOMAIN, + 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [G3_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP1, $supplier); createRoutingSnapshotFromProject( @@ -113,7 +111,7 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, - regions: '{' . G3_MOSCOW_CODE . '}', + regions: '{'.G3_MOSCOW_CODE.'}', ); // Exhaust the limit: delivered_today = G3_DAILY_LIMIT → SQL condition false in all phases. ConditionLevers::fillToLimit($projectP1); @@ -121,21 +119,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move // ── Client P2: tenant frozen (frozen_by_balance_at IS NOT NULL). // queryCandidates: WHERE tenants.frozen_by_balance_at IS NULL → FALSE → excluded from all phases. $tenantP2 = Tenant::factory()->create([ - 'balance_rub' => '999.00', + 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP2 = Project::factory()->create([ - 'tenant_id' => $tenantP2->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => G3_SUPPLIER_DOMAIN, - 'daily_limit_target' => G3_DAILY_LIMIT, + 'tenant_id' => $tenantP2->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => G3_SUPPLIER_DOMAIN, + 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [G3_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP2, $supplier); createRoutingSnapshotFromProject( @@ -144,7 +142,7 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, - regions: '{' . G3_MOSCOW_CODE . '}', + regions: '{'.G3_MOSCOW_CODE.'}', ); // Freeze the tenant: frozen_by_balance_at IS NOT NULL → excluded from all phases. ConditionLevers::freeze($tenantP2); @@ -152,21 +150,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move // ── Client P3: zero balance (balance_rub = 0). // queryCandidates: WHERE tenants.balance_rub > 0 → FALSE → excluded from all phases. $tenantP3 = Tenant::factory()->create([ - 'balance_rub' => '999.00', + 'balance_rub' => '999.00', 'frozen_by_balance_at' => null, ]); $projectP3 = Project::factory()->create([ - 'tenant_id' => $tenantP3->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => G3_SUPPLIER_DOMAIN, - 'daily_limit_target' => G3_DAILY_LIMIT, + 'tenant_id' => $tenantP3->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => G3_SUPPLIER_DOMAIN, + 'daily_limit_target' => G3_DAILY_LIMIT, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [G3_MOSCOW_CODE], + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [G3_MOSCOW_CODE], ]); linkProjectToSupplier($projectP3, $supplier); createRoutingSnapshotFromProject( @@ -175,14 +173,14 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move signalType: 'site', signalIdentifier: G3_SUPPLIER_DOMAIN, dailyLimit: G3_DAILY_LIMIT, - regions: '{' . G3_MOSCOW_CODE . '}', + regions: '{'.G3_MOSCOW_CODE.'}', ); // Drain balance: balance_rub = 0 → balance_rub > 0 condition fails in all phases. ConditionLevers::drainBalance($tenantP3); // Record counts BEFORE injection to detect any pre-existing rows. - $dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count(); - $chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count(); + $dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count(); + $chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count(); $balanceTxCountBefore = DB::connection('pgsql_supplier')->table('balance_transactions')->count(); // ── ACT — inject one lead and run the job synchronously ───────────────────── @@ -191,15 +189,15 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move $injectedLead = null; try { - $injector = new LeadInjector(); + $injector = new LeadInjector; $injectedLead = $injector->site( - domain: G3_SUPPLIER_DOMAIN, - phone: G3_LEAD_PHONE, - tag: 'Москва', + domain: G3_SUPPLIER_DOMAIN, + phone: G3_LEAD_PHONE, + tag: 'Москва', platform: G3_SUPPLIER_PLATFORM, - vid: 8_888_000_001, + vid: 8_888_000_001, ); - } catch (\Throwable $e) { + } catch (Throwable $e) { $thrownException = $e; } @@ -207,8 +205,8 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move // 1. No exception bubbled — the job must complete cleanly. expect($thrownException)->toBeNull( - 'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. ' . - 'Expected: no exception. Got: ' . ($thrownException?->getMessage() ?? 'none') + 'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. '. + 'Expected: no exception. Got: '.($thrownException?->getMessage() ?? 'none') ); // 2. The SupplierLead was created and is accessible. @@ -220,43 +218,43 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move /** @var SupplierLead $freshLead */ $freshLead = SupplierLead::find($injectedLead->id); expect($freshLead)->not->toBeNull( - 'FINDING: SupplierLead id=' . $injectedLead->id . ' not found in DB after injection.' + 'FINDING: SupplierLead id='.$injectedLead->id.' not found in DB after injection.' ); // 3. processed_at IS set — job stamped the lead as "processed" even though nobody received it. // WHERE: supplier_leads table, column processed_at — this is WHERE the orphan lead "rests". expect($freshLead->processed_at)->not->toBeNull( - 'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. ' . - 'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, ' . - 'even when deals_created_count=0. The orphan lead should rest in supplier_leads ' . + 'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. '. + 'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, '. + 'even when deals_created_count=0. The orphan lead should rest in supplier_leads '. 'with processed_at set (idempotency guard).' ); // 4. deals_created_count = 0 — no deals were created. expect((int) $freshLead->deals_created_count)->toBe(0, - 'FINDING: SupplierLead.deals_created_count expected 0 but got ' . - $freshLead->deals_created_count . '. ' . + 'FINDING: SupplierLead.deals_created_count expected 0 but got '. + $freshLead->deals_created_count.'. '. 'No eligible project was found — no deal should have been created.' ); // 5. No deals created across all three tenants. $newDealsCount = DB::connection('pgsql_supplier')->table('deals')->count() - $dealsCountBefore; expect($newDealsCount)->toBe(0, - 'FINDING: ' . $newDealsCount . ' deal(s) were created despite all clients being ineligible. ' . + 'FINDING: '.$newDealsCount.' deal(s) were created despite all clients being ineligible. '. 'P1(limit-exhausted), P2(frozen), P3(zero-balance) should all fail LeadRouter SQL filter.' ); // 6. No lead_charges created — no money moved. $newChargesCount = DB::connection('pgsql_supplier')->table('lead_charges')->count() - $chargesCountBefore; expect($newChargesCount)->toBe(0, - 'FINDING: ' . $newChargesCount . ' lead_charge row(s) created despite no eligible clients. ' . + 'FINDING: '.$newChargesCount.' lead_charge row(s) created despite no eligible clients. '. 'LedgerService::chargeForDelivery should not have been called.' ); // 7. No balance_transactions created — balances untouched. $newBalanceTxCount = DB::connection('pgsql_supplier')->table('balance_transactions')->count() - $balanceTxCountBefore; expect($newBalanceTxCount)->toBe(0, - 'FINDING: ' . $newBalanceTxCount . ' balance_transaction(s) created despite no eligible clients.' + 'FINDING: '.$newBalanceTxCount.' balance_transaction(s) created despite no eligible clients.' ); // 8. The orphan lead is visible/recorded — WHERE it rests. @@ -267,25 +265,25 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move ->where('deals_created_count', 0) ->count(); expect($orphanCount)->toBe(1, - 'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and ' . - 'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by ' . + 'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and '. + 'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by '. 'processed_at IS NOT NULL + deals_created_count = 0 (no error column set).' ); // ── REPORT ────────────────────────────────────────────────────────────────── - fwrite(STDOUT, PHP_EOL . '=== SCENARIO G3 ORPHAN LEAD REPORT ===' . PHP_EOL); - fwrite(STDOUT, 'SupplierLead id: ' . $freshLead->id . PHP_EOL); - fwrite(STDOUT, 'processed_at: ' . ($freshLead->processed_at?->toIso8601String() ?? 'NULL') . PHP_EOL); - fwrite(STDOUT, 'deals_created_count: ' . $freshLead->deals_created_count . PHP_EOL); - fwrite(STDOUT, 'error: ' . ($freshLead->error ?? 'NULL (no error)') . PHP_EOL); - fwrite(STDOUT, 'deals created (new): ' . $newDealsCount . PHP_EOL); - fwrite(STDOUT, 'lead_charges (new): ' . $newChargesCount . PHP_EOL); - fwrite(STDOUT, 'balance_transactions(new):' . $newBalanceTxCount . PHP_EOL); - fwrite(STDOUT, PHP_EOL . 'WHERE the orphan lead rests:' . PHP_EOL); - fwrite(STDOUT, ' Table: supplier_leads' . PHP_EOL); - fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0' . PHP_EOL); - fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).' . PHP_EOL); - fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).' . PHP_EOL); - fwrite(STDOUT, '=== END G3 REPORT ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== SCENARIO G3 ORPHAN LEAD REPORT ==='.PHP_EOL); + fwrite(STDOUT, 'SupplierLead id: '.$freshLead->id.PHP_EOL); + fwrite(STDOUT, 'processed_at: '.($freshLead->processed_at?->toIso8601String() ?? 'NULL').PHP_EOL); + fwrite(STDOUT, 'deals_created_count: '.$freshLead->deals_created_count.PHP_EOL); + fwrite(STDOUT, 'error: '.($freshLead->error ?? 'NULL (no error)').PHP_EOL); + fwrite(STDOUT, 'deals created (new): '.$newDealsCount.PHP_EOL); + fwrite(STDOUT, 'lead_charges (new): '.$newChargesCount.PHP_EOL); + fwrite(STDOUT, 'balance_transactions(new):'.$newBalanceTxCount.PHP_EOL); + fwrite(STDOUT, PHP_EOL.'WHERE the orphan lead rests:'.PHP_EOL); + fwrite(STDOUT, ' Table: supplier_leads'.PHP_EOL); + fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0'.PHP_EOL); + fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).'.PHP_EOL); + fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).'.PHP_EOL); + fwrite(STDOUT, '=== END G3 REPORT ==='.PHP_EOL.PHP_EOL); })->group('imitation'); diff --git a/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php b/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php index 7e5a1363..50953d87 100644 --- a/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php +++ b/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php @@ -46,12 +46,14 @@ declare(strict_types=1); * { "status": "accepted", "supplier_lead_id": } */ +use App\Jobs\RouteSupplierLeadJob; use App\Models\SupplierLead; use App\Models\SupplierProject; use App\Models\SystemSetting; use App\Services\DaData\DaDataPhoneClient; use App\Services\LeadRegionResolver; use App\Support\RussianRegions; +use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; @@ -82,7 +84,7 @@ beforeEach(function (): void { // The main project's .env has a real key; phpunit.xml in this worktree has no APP_KEY. // This fix is scoped to this file's tests only (config() changes are per-request). if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) { - config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]); + config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]); app('encrypter')->__construct( str_repeat('a', 32), config('app.cipher', 'AES-256-CBC') @@ -102,8 +104,8 @@ beforeEach(function (): void { // Seed pricing tiers (required by RouteSupplierLeadJob/LedgerService path). try { - (new \Database\Seeders\PricingTierSeeder())->run(); - } catch (\Throwable) { + (new PricingTierSeeder)->run(); + } catch (Throwable) { // Already seeded or not needed for this specific test. } }); @@ -117,8 +119,8 @@ function makeG5Lead(string $phone, string $tag = ''): SupplierLead return SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, - 'phone' => $phone, - 'raw_payload' => ['tag' => $tag], + 'phone' => $phone, + 'raw_payload' => ['tag' => $tag], ]); } @@ -128,25 +130,25 @@ function makeG5Lead(string $phone, string $tag = ''): SupplierLead function insertG5PhoneRange(int $defCode, int $from, int $to, int $subjectCode): void { $importId = DB::table('phone_ranges_imports')->insertGetId([ - 'imported_at' => now(), - 'source_url' => 'test://rossvyaz-g5g6', - 'rows_inserted' => 1, - 'rows_updated' => 0, + 'imported_at' => now(), + 'source_url' => 'test://rossvyaz-g5g6', + 'rows_inserted' => 1, + 'rows_updated' => 0, 'checksum_sha256' => hash('sha256', "g5g6-{$defCode}-{$from}-{$to}-{$subjectCode}"), - 'status' => 'completed', - 'completed_at' => now(), + 'status' => 'completed', + 'completed_at' => now(), ]); DB::table('phone_ranges')->insert([ - 'def_code' => $defCode, - 'from_num' => $from, - 'to_num' => $to, - 'operator' => 'test-operator', - 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', + 'def_code' => $defCode, + 'from_num' => $from, + 'to_num' => $to, + 'operator' => 'test-operator', + 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', 'region_normalized' => null, - 'subject_code' => $subjectCode, - 'imported_at' => now(), - 'import_id' => $importId, + 'subject_code' => $subjectCode, + 'imported_at' => now(), + 'import_id' => $importId, ]); } @@ -162,11 +164,11 @@ it('G5a: qc=2 + empty tag → source=unknown (FINDING: plan says tag, actual is // We assert REAL behaviour. config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000201', qc: 2, region: null, provider: null); + $fake = (new FakeDaDataPhoneClient)->stub('79990000201', qc: 2, region: null, provider: null); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79990000201', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); // REAL behaviour: empty tag → tagCode=null → source='unknown', NOT 'tag' expect($res->source)->toBe('unknown') @@ -180,12 +182,12 @@ it('G5a: qc=2 + valid tag → source=tag (Россвязь still skipped)', func // Россвязь is never consulted for qc=2 (the code path jumps to tagFallback). config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000202', qc: 2, region: null, provider: null); + $fake = (new FakeDaDataPhoneClient)->stub('79990000202', qc: 2, region: null, provider: null); app()->instance(DaDataPhoneClient::class, $fake); $moscowCode = RussianRegions::nameToCode()['Москва']; $lead = makeG5Lead('79990000202', tag: 'Москва'); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('tag') ->and($res->subjectCode)->toBe($moscowCode) @@ -197,11 +199,11 @@ it('G5a: qc=7 + empty tag → source=unknown (same as qc=2, Россвязь ski // qc=7 (иностранец) behaves identically to qc=2: goes straight to tagFallback(). config(['services.dadata.enabled' => true]); - $fake = (new FakeDaDataPhoneClient())->stub('79990000203', qc: 7, region: null, provider: null); + $fake = (new FakeDaDataPhoneClient)->stub('79990000203', qc: 7, region: null, provider: null); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79990000203', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() @@ -224,11 +226,11 @@ it('G5b: DaData throws DaDataException + seeded phone range → source=rossvyaz, $tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // ordinal 77 insertG5PhoneRange(defCode: 988, from: 5550000, to: 5559999, subjectCode: $tyumenCode); - $fake = (new FakeDaDataPhoneClient())->stubThrows('79885550211'); + $fake = (new FakeDaDataPhoneClient)->stubThrows('79885550211'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79885550211', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() @@ -245,11 +247,11 @@ it('G5b: qc=1 (не уточнён) + seeded phone range → source=rossvyaz, ro $voronezCode = RussianRegions::nameToCode()['Воронежская область']; // ordinal 42 insertG5PhoneRange(defCode: 988, from: 6660000, to: 6669999, subjectCode: $voronezCode); - $fake = (new FakeDaDataPhoneClient())->stub('79886660311', qc: 1, region: null, provider: null); + $fake = (new FakeDaDataPhoneClient)->stub('79886660311', qc: 1, region: null, provider: null); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79886660311', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() @@ -268,11 +270,11 @@ it('G5c: qc=2 + no phone range seeded + empty tag → source=unknown', function config(['services.dadata.enabled' => true]); // No phone_ranges seeded for this phone. - $fake = (new FakeDaDataPhoneClient())->stub('79990000304', qc: 2, region: null, provider: null); + $fake = (new FakeDaDataPhoneClient)->stub('79990000304', qc: 2, region: null, provider: null); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79990000304', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() @@ -285,11 +287,11 @@ it('G5c: DaData throws + no phone range seeded + empty tag → source=unknown', config(['services.dadata.enabled' => true]); // No phone_ranges seeded for this phone. - $fake = (new FakeDaDataPhoneClient())->stubThrows('79990000305'); + $fake = (new FakeDaDataPhoneClient)->stubThrows('79990000305'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeG5Lead('79990000305', tag: ''); - $res = app(LeadRegionResolver::class)->resolve($lead); + $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() @@ -317,16 +319,16 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces $vid = 987654321; // Deterministic vid, outside auto-generated ranges from LeadInjector $payload = [ - 'vid' => $vid, + 'vid' => $vid, 'project' => 'B1_g6test.example.com', - 'phone' => '79991112233', - 'time' => time(), - 'tag' => 'Москва', + 'phone' => '79991112233', + 'time' => time(), + 'tag' => 'Москва', ]; // First request → should be accepted (202). $first = $this->postJson( - '/api/webhook/supplier/' . G5G6_SECRET, + '/api/webhook/supplier/'.G5G6_SECRET, $payload ); @@ -340,7 +342,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces // Second request — same vid, same payload → dedup path (200). $second = $this->postJson( - '/api/webhook/supplier/' . G5G6_SECRET, + '/api/webhook/supplier/'.G5G6_SECRET, $payload ); @@ -349,7 +351,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces // Exact response shape from controller lines 96-99: // { "status": "already_processed", "supplier_lead_id": } $second->assertJson([ - 'status' => 'already_processed', + 'status' => 'already_processed', 'supplier_lead_id' => $firstLeadId, ]); @@ -358,7 +360,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces // RouteSupplierLeadJob dispatched exactly once (for the first request). // The second request returns early before any dispatch. - Bus::assertDispatchedTimes(\App\Jobs\RouteSupplierLeadJob::class, 1); + Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1); })->group('imitation'); it('G6: second request returns the SAME supplier_lead_id as the first', function (): void { @@ -371,14 +373,14 @@ it('G6: second request returns the SAME supplier_lead_id as the first', function $vid = 987654322; // Different deterministic vid from G6 test above $payload = [ - 'vid' => $vid, + 'vid' => $vid, 'project' => 'B1_g6test.example.com', - 'phone' => '79991112244', - 'time' => time(), + 'phone' => '79991112244', + 'time' => time(), ]; - $first = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload); - $second = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload); + $first = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload); + $second = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload); $first->assertStatus(202); $second->assertStatus(200); diff --git a/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php b/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php index 7fc11c99..d6e4d286 100644 --- a/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php +++ b/app/tests/Feature/Imitation/ScenarioX1X3_SubstitutionJournalTest.php @@ -80,7 +80,7 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class); // ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ──────────────────────────── /** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */ -const X1_LEAD_REGION = 29; +const X1_LEAD_REGION = 29; /** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */ const X1_CLIENT_REGION = 37; /** RussianRegions::CODE_TO_NAME[82] = 'Москва' */ @@ -103,9 +103,9 @@ beforeEach(function (): void { // DaData config defaults — individual tests override as needed. config([ - 'services.dadata.enabled' => true, - 'services.dadata.api_key' => 'fake-key', - 'services.dadata.secret' => 'fake-secret', + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); @@ -141,40 +141,40 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea // One supplier (B1 site). $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => X1_DOMAIN, + 'unique_key' => X1_DOMAIN, ]); // One client: subscribed to R_client=37 (Белгородская обл.) ONLY. // No all-RF client → phases 1+2 both empty → phase 3 fires. $tenant = Tenant::factory()->create([ - 'balance_rub' => '99999.00', + 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => X1_DOMAIN, - 'daily_limit_target' => 10, + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => X1_DOMAIN, + 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'preflight_blocked_at' => null, - 'regions' => [X1_CLIENT_REGION], // {37} + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => 127, + 'preflight_blocked_at' => null, + 'regions' => [X1_CLIENT_REGION], // {37} ]); linkProjectToSupplier($project, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $project, - date: $activeDate, - signalType: 'site', + project: $project, + date: $activeDate, + signalType: 'site', signalIdentifier: X1_DOMAIN, - dailyLimit: 10, - regions: '{' . X1_CLIENT_REGION . '}', // '{37}' + dailyLimit: 10, + regions: '{'.X1_CLIENT_REGION.'}', // '{37}' ); // Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0. @@ -183,19 +183,19 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea $leadPhone = '79292900001'; $leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край' - $fakeDaData = new FakeDaDataPhoneClient(); + $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ─────────────────────────────────────────────────────────────────── - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead = $injector->site( - domain: X1_DOMAIN, - phone: $leadPhone, - tag: null, + domain: X1_DOMAIN, + phone: $leadPhone, + tag: null, platform: 'B1', - vid: 9_120_001_001, + vid: 9_120_001_001, ); // ── ASSERT ──────────────────────────────────────────────────────────────── @@ -212,51 +212,51 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea ->first(); // Diagnostic output. - fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL); - fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL); - fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL); - fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL); - fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== X1 SUBSTITUTION ==='.PHP_EOL); + fwrite(STDOUT, "Lead phone: {$leadPhone}".PHP_EOL); + fwrite(STDOUT, 'R_lead (real resolved): '.X1_LEAD_REGION." ({$leadRegionName})".PHP_EOL); + fwrite(STDOUT, 'R_client (client region): '.X1_CLIENT_REGION.' ('.RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION].')'.PHP_EOL); + fwrite(STDOUT, 'deal found: '.($deal !== null ? 'YES' : 'NO').PHP_EOL); if ($deal !== null) { - fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL); - fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL); - fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL); + fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}".PHP_EOL); + fwrite(STDOUT, "deals.city: {$deal->city}".PHP_EOL); + fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}".PHP_EOL); } if ($logRow !== null) { - fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL); - fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL); - fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL); - fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL); - fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL); + fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}".PHP_EOL); + fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}".PHP_EOL); + fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}".PHP_EOL); + fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}".PHP_EOL); + fwrite(STDOUT, "log.region_source: {$logRow->region_source}".PHP_EOL); } else { - fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL); + fwrite(STDOUT, 'log row: NOT FOUND'.PHP_EOL); } - fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, '=== END X1 ==='.PHP_EOL.PHP_EOL); // A deal must have been created. expect($deal)->not->toBeNull( - 'FINDING: No deal was created for the tenant. ' . - 'The phase-3 fallback (any region) may not be reaching this client, ' . + 'FINDING: No deal was created for the tenant. '. + 'The phase-3 fallback (any region) may not be reaching this client, '. 'or the snapshot is missing.' ); if ($deal !== null) { // deals.subject_code must be R_client (substituted to client's region on step 3). expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION, - 'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' . - 'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' . - 'Got: ' . $deal->subject_code . '. ' . - 'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' . + 'FINDING: deals.subject_code should be '.X1_CLIENT_REGION.' (R_client, Белгородская обл.) '. + 'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. '. + 'Got: '.$deal->subject_code.'. '. + 'If got R_lead='.X1_LEAD_REGION.': substitution is not firing (routingStep capture or pickSubstituteRegion failed). '. 'If got null: snapshot.regions may not be picked up correctly.' ); // deals.city must be the name of R_lead (real resolved region), NOT R_client. // Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида. expect($deal->city)->toBe($leadRegionName, - 'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' . - 'Got: "' . $deal->city . '". ' . - 'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' . - 'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' . + 'FINDING: deals.city should be "'.$leadRegionName.'" (name of R_lead='.X1_LEAD_REGION.', real lead region). '. + 'Got: "'.$deal->city.'". '. + 'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. '. + 'If city = "'.RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION].'": '. 'prod code erroneously uses $dealSubjectCode for city.' ); @@ -269,62 +269,61 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea } expect($regionSubstituted)->toBeTrue( - 'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' . - 'Got raw value: "' . $deal->region_substituted . '". ' . + 'FINDING: deals.region_substituted should be TRUE on routing_step=3. '. + 'Got raw value: "'.$deal->region_substituted.'". '. 'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.' ); } // lead_region_resolution_log must have a row. expect($logRow)->not->toBeNull( - 'FINDING: lead_region_resolution_log has no row for this lead. ' . - 'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' . + 'FINDING: lead_region_resolution_log has no row for this lead. '. + 'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). '. 'Check for partition missing (received_at date not matching any partition).' ); if ($logRow !== null) { // routing_step = 3. expect((int) $logRow->routing_step)->toBe(3, - 'FINDING: log.routing_step should be 3 (phase-3 fallback). ' . - 'Got: ' . $logRow->routing_step . '. ' . - 'If 1: exact match fired (snapshot.regions may be wrong). ' . + 'FINDING: log.routing_step should be 3 (phase-3 fallback). '. + 'Got: '.$logRow->routing_step.'. '. + 'If 1: exact match fired (snapshot.regions may be wrong). '. 'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).' ); // subject_code_resolved = R_lead (the actual resolved code, not substituted). expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION, - 'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' . - 'Got: ' . $logRow->subject_code_resolved . '.' + 'FINDING: log.subject_code_resolved should be '.X1_LEAD_REGION.' (R_lead, real resolved). '. + 'Got: '.$logRow->subject_code_resolved.'.' ); // actual_subject_code = R_lead. // RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved). expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION, - 'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' . - 'Got: ' . $logRow->actual_subject_code . '. ' . + 'FINDING: log.actual_subject_code should be '.X1_LEAD_REGION.' (R_lead, real lead region). '. + 'Got: '.$logRow->actual_subject_code.'. '. 'actualSubjectCode is set equal to subjectCode in RegionResolution::make().' ); // substituted_subject_code = R_client (the first code from snapshot.regions). // pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION. expect($logRow->substituted_subject_code)->not->toBeNull( - 'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' . - 'Got null. ' . - 'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' . + 'FINDING: log.substituted_subject_code should be '.X1_CLIENT_REGION.' (R_client) on step 3. '. + 'Got null. '. + 'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. '. 'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).' ); if ($logRow->substituted_subject_code !== null) { expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION, - 'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' . - 'Got: ' . $logRow->substituted_subject_code . '. ' . + 'FINDING: log.substituted_subject_code should be '.X1_CLIENT_REGION.' (R_client=Белгородская обл.). '. + 'Got: '.$logRow->substituted_subject_code.'. '. 'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.' ); } } })->group('imitation'); - // ══════════════════════════════════════════════════════════════════════════════ // SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown) // ══════════════════════════════════════════════════════════════════════════════ @@ -368,9 +367,9 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so // This means RouteSupplierLeadJob still runs LeadRegionResolver, updates // supplier_leads.region_source, then calls logRegionResolution (with empty selected). $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', + 'platform' => 'B1', 'signal_type' => 'site', - 'unique_key' => X3_DOMAIN, + 'unique_key' => X3_DOMAIN, ]); // ── Lead 1: source = 'dadata' ────────────────────────────────────────────── @@ -378,13 +377,13 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so $phone1 = '79310000001'; $region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва' - $fake1 = new FakeDaDataPhoneClient(); + $fake1 = new FakeDaDataPhoneClient; $fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake1); config(['services.dadata.enabled' => true]); - $injector = new LeadInjector(); + $injector = new LeadInjector; $lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001); // ── Lead 2: source = 'rossvyaz' ──────────────────────────────────────────── @@ -394,27 +393,27 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so $phone2 = '79310000002'; // DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it. $importId2 = DB::table('phone_ranges_imports')->insertGetId([ - 'imported_at' => now(), - 'source_url' => 'test://x3-rossvyaz', - 'rows_inserted' => 1, - 'rows_updated' => 0, + 'imported_at' => now(), + 'source_url' => 'test://x3-rossvyaz', + 'rows_inserted' => 1, + 'rows_updated' => 0, 'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'), - 'status' => 'completed', - 'completed_at' => now(), + 'status' => 'completed', + 'completed_at' => now(), ]); DB::table('phone_ranges')->insert([ - 'def_code' => 931, - 'from_num' => 0, - 'to_num' => 9999999, - 'operator' => 'test-op-x3', - 'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW], + 'def_code' => 931, + 'from_num' => 0, + 'to_num' => 9999999, + 'operator' => 'test-op-x3', + 'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW], 'region_normalized' => null, - 'subject_code' => X1_MOSCOW, - 'imported_at' => now(), - 'import_id' => $importId2, + 'subject_code' => X1_MOSCOW, + 'imported_at' => now(), + 'import_id' => $importId2, ]); - $fake2 = new FakeDaDataPhoneClient(); + $fake2 = new FakeDaDataPhoneClient; $fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь app()->instance(DaDataPhoneClient::class, $fake2); config(['services.dadata.enabled' => true]); @@ -455,59 +454,59 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so $source3 = $rows[$lead3->id]->region_source ?? 'MISSING'; $source4 = $rows[$lead4->id]->region_source ?? 'MISSING'; - fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL); - fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL); - fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL); - fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL); - fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL); + fwrite(STDOUT, PHP_EOL.'=== X3 SOURCE BREAKDOWN ==='.PHP_EOL); + fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: ".($rows[$lead1->id]->resolved_subject_code ?? 'null').PHP_EOL); + fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: ".($rows[$lead2->id]->resolved_subject_code ?? 'null').PHP_EOL); + fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: ".($rows[$lead3->id]->resolved_subject_code ?? 'null').PHP_EOL); + fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: ".($rows[$lead4->id]->resolved_subject_code ?? 'null').PHP_EOL); // Aggregate counts from supplier_leads. $actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds); $counts = array_count_values($actualSources); - fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL); - fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL); + fwrite(STDOUT, 'Source counts: '.json_encode($counts, JSON_UNESCAPED_UNICODE).PHP_EOL); + fwrite(STDOUT, '=== END X3 ==='.PHP_EOL.PHP_EOL); // ── ASSERT ──────────────────────────────────────────────────────────────── // Lead 1: expect 'dadata'. expect($source1)->toBe('dadata', - "FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " . - "Got: '{$source1}'. " . - "If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " . + "FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. ". + "Got: '{$source1}'. ". + "If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). ". "If 'unknown': DaData is disabled or threw despite stub." ); // Lead 2: expect 'rossvyaz'. expect($source2)->toBe('rossvyaz', - "FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " . - "Got: '{$source2}'. " . - "If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " . + "FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. ". + "Got: '{$source2}'. ". + "If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). ". "If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)." ); // Lead 3: expect 'tag'. expect($source3)->toBe('tag', - "FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " . - "Got: '{$source3}'. " . - "KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " . - "Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " . - "is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " . - "'Москва' should map to code 82 via RegionTagResolver. " . + "FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. ". + "Got: '{$source3}'. ". + 'KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through '. + 'Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() '. + "is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). ". + "'Москва' should map to code 82 via RegionTagResolver. ". "If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver." ); // Lead 4: expect 'unknown'. expect($source4)->toBe('unknown', - "FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " . - "Got: '{$source4}'. " . - "If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " . + "FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. ". + "Got: '{$source4}'. ". + "If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. ". "If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)." ); // Aggregate assertion: 4 leads → exactly these 4 sources. $expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1]; expect($counts)->toEqual($expectedCounts, - 'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' . - 'Got: ' . json_encode($counts) . '. See individual source assertions above for details.' + 'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. '. + 'Got: '.json_encode($counts).'. See individual source assertions above for details.' ); })->group('imitation'); diff --git a/app/tests/Feature/Imitation/SeederTest.php b/app/tests/Feature/Imitation/SeederTest.php index e7c95ae9..6e37c3a2 100644 --- a/app/tests/Feature/Imitation/SeederTest.php +++ b/app/tests/Feature/Imitation/SeederTest.php @@ -52,7 +52,7 @@ beforeEach(function (): void { $repoRoot = dirname(base_path()); // Step 1: Load the base schema.sql (creates most tables, triggers, RLS, etc.) - $schemaPath = $repoRoot . DIRECTORY_SEPARATOR . 'db' . DIRECTORY_SEPARATOR . 'schema.sql'; + $schemaPath = $repoRoot.DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql'; if (! is_readable($schemaPath)) { throw new RuntimeException("schema.sql not found: {$schemaPath}"); } @@ -67,7 +67,8 @@ beforeEach(function (): void { ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED SQL); - } catch (Throwable) { /* already exists — idempotent */ } + } catch (Throwable) { /* already exists — idempotent */ + } // Step 3: Create tables that are in PARTITIONED_TABLES but NOT in schema.sql // (they were added via delta migrations). partitions:create-months needs them. @@ -98,7 +99,8 @@ beforeEach(function (): void { 'CREATE INDEX project_routing_snapshots_tenant_date_idx ON project_routing_snapshots (tenant_id, snapshot_date)' ); - } catch (Throwable) { /* idempotent */ } + } catch (Throwable) { /* idempotent */ + } try { DB::statement('ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY'); @@ -107,7 +109,8 @@ beforeEach(function (): void { ON project_routing_snapshots USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)" ); - } catch (Throwable) { /* idempotent */ } + } catch (Throwable) { /* idempotent */ + } // 3b. phone_ranges_imports + phone_ranges + lead_region_resolution_log // (delta migration 2026_05_31_100000) @@ -174,7 +177,8 @@ beforeEach(function (): void { try { DB::statement('ALTER TABLE lead_region_resolution_log ENABLE ROW LEVEL SECURITY'); - } catch (Throwable) { /* idempotent */ } + } catch (Throwable) { /* idempotent */ + } // Step 4: Create month partitions — with shared PDO, pgsql_supplier sees // all the tables we just created within the same transaction. @@ -192,12 +196,13 @@ beforeEach(function (): void { ['migration' => $migration], ['batch' => 1], ); - } catch (Throwable) { /* ok if migrations table doesn't exist */ } + } catch (Throwable) { /* ok if migrations table doesn't exist */ + } } } // Seed pricing tiers (required by LedgerService::chargeForDelivery). - (new PricingTierSeeder())->run(); + (new PricingTierSeeder)->run(); // Allow cross-tenant reads during seeding. DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); @@ -216,7 +221,7 @@ beforeEach(function (): void { * All project names are prefixed `IMIT-single-`. */ it('seeds the single-project matrix', function (): void { - (new ImitationClientsSeeder())->run(); + (new ImitationClientsSeeder)->run(); expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36); })->group('imitation'); diff --git a/app/tests/Feature/Imitation/SnapshotForgeTest.php b/app/tests/Feature/Imitation/SnapshotForgeTest.php index e19d4c05..f3a657b6 100644 --- a/app/tests/Feature/Imitation/SnapshotForgeTest.php +++ b/app/tests/Feature/Imitation/SnapshotForgeTest.php @@ -25,19 +25,19 @@ beforeEach(function (): void { it('rebuild() creates a project_routing_snapshots row for the active date', function (): void { // Arrange: tenant with positive balance (required by snapshot:rebuild eligibility) $tenant = Tenant::factory()->create([ - 'balance_rub' => '500.00', + 'balance_rub' => '500.00', 'frozen_by_balance_at' => null, ]); // Arrange: active project with call signal (required by snapshot:rebuild // INSERT: signal_type NOT NULL CHECK IN ('call','site','sms')) $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'call', - 'signal_identifier' => '79161234567', - 'daily_limit_target' => 10, - 'delivery_days_mask' => 127, // all 7 days + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'call', + 'signal_identifier' => '79161234567', + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, // all 7 days 'preflight_blocked_at' => null, ]); diff --git a/app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php b/app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php index 40577d35..23d7ae5b 100644 --- a/app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php +++ b/app/tests/Feature/Imitation/TopologyMoneyIntakeTest.php @@ -41,6 +41,7 @@ use App\Models\Project; use App\Models\SupplierProject; use App\Models\SystemSetting; use App\Models\Tenant; +use App\Models\User; use App\Services\DaData\DaDataPhoneClient; use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -80,9 +81,9 @@ beforeEach(function (): void { // Disable DaData by default; individual tests enable as needed. config([ - 'services.dadata.enabled' => false, - 'services.dadata.api_key' => 'fake-key', - 'services.dadata.secret' => 'fake-secret', + 'services.dadata.enabled' => false, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); }); @@ -99,13 +100,13 @@ beforeEach(function (): void { function tmi_fixAppKey(): void { if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) { - config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]); + config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]); try { app('encrypter')->__construct( str_repeat('a', 32), config('app.cipher', 'AES-256-CBC') ); - } catch (\Throwable) { + } catch (Throwable) { // Encrypter may already be initialized; ignore re-init errors. } } @@ -146,7 +147,7 @@ function tmi_clearIpAllowlist(): void it('G1: project linked to two supplier sources receives leads from each', function (): void { config(['services.dadata.enabled' => true]); - $seeder = new ImitationClientsSeeder(); + $seeder = new ImitationClientsSeeder; $g1 = $seeder->seedG1('IMIT-G1-topo'); /** @var Tenant $tenant */ @@ -159,17 +160,17 @@ it('G1: project linked to two supplier sources receives leads from each', functi // Set ample balance. DB::table('tenants')->where('id', $tenant->id)->update([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, - 'delivered_in_month' => 0, + 'delivered_in_month' => 0, ]); // Set project active, all-RF regions (empty = all), all days. DB::table('projects')->where('id', $project->id)->update([ - 'is_active' => true, - 'regions' => '{}', + 'is_active' => true, + 'regions' => '{}', 'delivery_days_mask' => 127, - 'delivered_today' => 0, + 'delivered_today' => 0, ]); $activeDate = SnapshotForge::activeDate(); @@ -178,28 +179,28 @@ it('G1: project linked to two supplier sources receives leads from each', functi // (project_supplier_links), not signal_identifier in snapshot. The snapshot just // needs to exist with the correct project_id and date. createRoutingSnapshotFromProject( - project: $project, - date: $activeDate, - signalType: 'site', + project: $project, + date: $activeDate, + signalType: 'site', signalIdentifier: $project->signal_identifier ?? 'g1-proj.test', - dailyLimit: 50, - regions: '{}', + dailyLimit: 50, + regions: '{}', ); // Lead via B1 source. Inject using the supplier's unique_key so resolveOrStub // finds the SAME supplier_project that the pivot links to. $phoneB1 = '79161111001'; - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; $fake->stub($phoneB1, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); - $injector = new LeadInjector(); + $injector = new LeadInjector; $leadB1 = $injector->site( - domain: $supplierB1->unique_key ?? 'g1-b1.test', - phone: $phoneB1, - tag: 'Москва', + domain: $supplierB1->unique_key ?? 'g1-b1.test', + phone: $phoneB1, + tag: 'Москва', platform: $supplierB1->platform, - vid: 9_100_000_001, + vid: 9_100_000_001, ); $dealsB1 = DB::connection('pgsql_supplier') @@ -208,7 +209,7 @@ it('G1: project linked to two supplier sources receives leads from each', functi ->count(); expect($dealsB1)->toBeGreaterThan(0, - 'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. ' . + 'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. '. "supplier_project_id={$supplierB1->id}, project_id={$project->id}" ); })->group('imitation'); @@ -222,7 +223,7 @@ it('G1: project linked to two supplier sources receives leads from each', functi it('G2: two clients on same supplier each receive at least one lead', function (): void { config(['services.dadata.enabled' => true]); - $seeder = new ImitationClientsSeeder(); + $seeder = new ImitationClientsSeeder; $g2 = $seeder->seedG2( ['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]], ['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]], @@ -239,23 +240,23 @@ it('G2: two clients on same supplier each receive at least one lead', function ( foreach ([$projects[0], $projects[1]] as $idx => $project) { DB::table('tenants')->where('id', $tenants[$idx]->id)->update([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, - 'delivered_in_month' => 0, + 'delivered_in_month' => 0, ]); DB::table('projects')->where('id', $project->id)->update([ - 'is_active' => true, - 'regions' => '{' . TOPO_MOSCOW . '}', + 'is_active' => true, + 'regions' => '{'.TOPO_MOSCOW.'}', 'delivery_days_mask' => 127, - 'delivered_today' => 0, + 'delivered_today' => 0, ]); createRoutingSnapshotFromProject( - project: $project, - date: $activeDate, - signalType: 'site', + project: $project, + date: $activeDate, + signalType: 'site', signalIdentifier: $project->signal_identifier, - dailyLimit: 20, - regions: '{' . TOPO_MOSCOW . '}', + dailyLimit: 20, + regions: '{'.TOPO_MOSCOW.'}', ); } @@ -265,22 +266,22 @@ it('G2: two clients on same supplier each receive at least one lead', function ( // and RouteSupplierLeadJob::resolveOrStub() looks up supplier_projects by (platform, unique_key). $supplierDomain = $supplier->unique_key ?? 'g2-src.test'; - $fake = new FakeDaDataPhoneClient(); + $fake = new FakeDaDataPhoneClient; for ($i = 1; $i <= 10; $i++) { - $phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT); + $phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT); $fake->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); } app()->instance(DaDataPhoneClient::class, $fake); - $injector = new LeadInjector(); + $injector = new LeadInjector; for ($i = 1; $i <= 10; $i++) { - $phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT); + $phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT); $injector->site( - domain: $supplierDomain, - phone: $phone, - tag: 'Москва', + domain: $supplierDomain, + phone: $phone, + tag: 'Москва', platform: $supplier->platform, - vid: 9_200_000_000 + $i, + vid: 9_200_000_000 + $i, ); } @@ -304,13 +305,13 @@ it('G2: two clients on same supplier each receive at least one lead', function ( // both should receive deals. This is probabilistic but seed-based. // If one is 0, it indicates routing bias — report as FINDING. expect($deals0)->toBeGreaterThan(0, - "FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. " . - "Both clients have equal weight. Possible routing bias." + "FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. ". + 'Both clients have equal weight. Possible routing bias.' ); expect($deals1)->toBeGreaterThan(0, - "FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. " . - "Both clients have equal weight. Possible routing bias." + "FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. ". + 'Both clients have equal weight. Possible routing bias.' ); })->group('imitation'); @@ -323,7 +324,7 @@ it('G2: two clients on same supplier each receive at least one lead', function ( it('G4: two projects on same supplier with different regions each receive region-matching leads', function (): void { config(['services.dadata.enabled' => true]); - $seeder = new ImitationClientsSeeder(); + $seeder = new ImitationClientsSeeder; $g4 = $seeder->seedG4(TOPO_MOSCOW, TOPO_SPB); /** @var SupplierProject $supplier */ @@ -336,14 +337,14 @@ it('G4: two projects on same supplier with different regions each receive region $projectB = $g4['projectB']; // regions=[83] СПб DB::table('tenants')->where('id', $tenant->id)->update([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, - 'delivered_in_month' => 0, + 'delivered_in_month' => 0, ]); foreach ([$projectA, $projectB] as $project) { DB::table('projects')->where('id', $project->id)->update([ - 'is_active' => true, + 'is_active' => true, 'delivery_days_mask' => 127, 'delivered_today' => 0, ]); @@ -352,38 +353,38 @@ it('G4: two projects on same supplier with different regions each receive region $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $projectA, - date: $activeDate, - signalType: 'site', + project: $projectA, + date: $activeDate, + signalType: 'site', signalIdentifier: $projectA->signal_identifier, - dailyLimit: 50, - regions: '{' . TOPO_MOSCOW . '}', + dailyLimit: 50, + regions: '{'.TOPO_MOSCOW.'}', ); createRoutingSnapshotFromProject( - project: $projectB, - date: $activeDate, - signalType: 'site', + project: $projectB, + date: $activeDate, + signalType: 'site', signalIdentifier: $projectB->signal_identifier, - dailyLimit: 50, - regions: '{' . TOPO_SPB . '}', + dailyLimit: 50, + regions: '{'.TOPO_SPB.'}', ); // Use the supplier's unique_key as the domain — resolveOrStub looks up by (platform, unique_key). $supplierKey = $supplier->unique_key ?? 'g4-src.test'; - $injector = new LeadInjector(); + $injector = new LeadInjector; // Lead A: Москва phone → resolved to code 82 → should go to projectA only. $phoneMoscow = '79163000001'; - $fakeMoscow = (new FakeDaDataPhoneClient())->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС'); + $fakeMoscow = (new FakeDaDataPhoneClient)->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeMoscow); $injector->site( - domain: $supplierKey, - phone: $phoneMoscow, - tag: 'Москва', + domain: $supplierKey, + phone: $phoneMoscow, + tag: 'Москва', platform: $supplier->platform, - vid: 9_400_000_001, + vid: 9_400_000_001, ); $dealsAfterMoscowLead_A = DB::connection('pgsql_supplier') @@ -399,26 +400,26 @@ it('G4: two projects on same supplier with different regions each receive region ->count(); expect($dealsAfterMoscowLead_A)->toBeGreaterThan(0, - 'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). ' . + 'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). '. "projectA_id={$projectA->id}, projectB_id={$projectB->id}" ); expect($dealsAfterMoscowLead_B)->toBe(0, - "FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). " . + 'FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). '. "Expected 0 deals for projectB, got {$dealsAfterMoscowLead_B}." ); // Lead B: СПб phone → resolved to code 83 → should go to projectB only. $phoneSpb = '79163000002'; - $fakeSpb = (new FakeDaDataPhoneClient())->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон'); + $fakeSpb = (new FakeDaDataPhoneClient)->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fakeSpb); $injector->site( - domain: $supplierKey, - phone: $phoneSpb, - tag: 'Санкт-Петербург', + domain: $supplierKey, + phone: $phoneSpb, + tag: 'Санкт-Петербург', platform: $supplier->platform, - vid: 9_400_000_002, + vid: 9_400_000_002, ); $dealsAfterSpbLead_A = DB::connection('pgsql_supplier') @@ -435,12 +436,12 @@ it('G4: two projects on same supplier with different regions each receive region // projectA should still have only the first lead (unchanged). expect($dealsAfterSpbLead_A)->toBe($dealsAfterMoscowLead_A, - "FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). " . + 'FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). '. "Expected {$dealsAfterMoscowLead_A} deals for projectA, got {$dealsAfterSpbLead_A}." ); expect($dealsAfterSpbLead_B)->toBeGreaterThan(0, - 'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). ' . + 'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). '. "projectA_id={$projectA->id}, projectB_id={$projectB->id}" ); })->group('imitation'); @@ -471,27 +472,27 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a $expectedAmountRub = '500.00'; // 50000 / 100 $tenant = Tenant::factory()->create([ - 'balance_rub' => $initialBalance, + 'balance_rub' => $initialBalance, 'frozen_by_balance_at' => null, - 'delivered_in_month' => 0, + 'delivered_in_month' => 0, ]); - $user = \App\Models\User::factory()->create(['tenant_id' => $tenant->id]); + $user = User::factory()->create(['tenant_id' => $tenant->id]); $supplier = SupplierProject::factory()->create([ - 'platform' => 'B2', + 'platform' => 'B2', 'signal_type' => 'site', ]); $phone = '79164000001'; // PostgresIntArray cast requires PHP array, not '{...}' string literal. $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test', - 'regions' => [], // empty = all-RF; cast converts to '{}' + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test', + 'regions' => [], // empty = all-RF; cast converts to '{}' 'delivery_days_mask' => 127, - 'delivered_today' => 0, + 'delivered_today' => 0, 'delivered_in_month' => 0, ]); @@ -499,24 +500,24 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $project, - date: $activeDate, - signalType: 'site', + project: $project, + date: $activeDate, + signalType: 'site', signalIdentifier: $project->signal_identifier, - dailyLimit: 50, - regions: '{}', + dailyLimit: 50, + regions: '{}', ); - $fakeDaData = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + $fakeDaData = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); - $injector = new LeadInjector(); + $injector = new LeadInjector; $injector->site( - domain: ltrim($project->signal_identifier, '/'), - phone: $phone, - tag: 'Москва', + domain: ltrim($project->signal_identifier, '/'), + phone: $phone, + tag: 'Москва', platform: $supplier->platform, - vid: 9_500_000_001, + vid: 9_500_000_001, ); // ── Reload tenant to see updated balance ──────────────────────────────── @@ -539,7 +540,7 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a expect($charge)->not->toBeNull('FINDING: lead_charges row missing after delivery.'); expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks, - "FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, " . + "FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, ". "expected {$expectedPriceKopecks} (tier 1, delivered_in_month was 0 → count+1=1)." ); expect($charge->charge_source)->toBe('rub', @@ -566,7 +567,7 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a // The absolute value must equal the tier price. $absAmount = ltrim($amountRub, '-'); expect($absAmount)->toBe($expectedAmountRub, - "FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', " . + "FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', ". "expected '{$expectedAmountRub}' (kopecks={$expectedPriceKopecks} → rub=500.00)." ); @@ -577,16 +578,16 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a $actualNewBalance = (string) $tenantAfter->balance_rub; // Normalize: bcmath may return '500.00'; DB may store '500.00' as well. expect($actualNewBalance)->toBe($expectedNewBalance, - "FINDING: KOPECK LOSS detected. " . - "Initial balance: {$initialBalance}, price: {$expectedAmountRub}. " . - "Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. " . - "This indicates floating-point or bcmath precision error." + 'FINDING: KOPECK LOSS detected. '. + "Initial balance: {$initialBalance}, price: {$expectedAmountRub}. ". + "Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. ". + 'This indicates floating-point or bcmath precision error.' ); // balance_rub_after in balance_transactions must match actual tenant balance. $btBalanceAfter = (string) $bt->balance_rub_after; expect($btBalanceAfter)->toBe($expectedNewBalance, - "FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', " . + "FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', ". "expected '{$expectedNewBalance}'. Ledger audit trail inconsistency." ); @@ -596,14 +597,14 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a ->first(); expect($slc)->not->toBeNull( - 'FINDING: supplier_lead_costs row missing after delivery. ' . + 'FINDING: supplier_lead_costs row missing after delivery. '. 'LedgerService should insert it when supplier resolved via platform B2.' ); // ── 5. delivered_in_month incremented ─────────────────────────────────── expect((int) $tenantAfter->delivered_in_month)->toBe(1, - "FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, " . - "expected 1 after first lead delivery (started at 0)." + "FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, ". + 'expected 1 after first lead delivery (started at 0).' ); })->group('imitation'); @@ -618,14 +619,14 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void // delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks = 450.00 rub). $expectedPriceKopecks = 45000; - $expectedAmountRub = '450.00'; + $expectedAmountRub = '450.00'; $tenant = Tenant::factory()->create([ - 'balance_rub' => '9999.00', + 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, - 'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1) + 'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1) ]); - \App\Models\User::factory()->create(['tenant_id' => $tenant->id]); + User::factory()->create(['tenant_id' => $tenant->id]); $supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']); $supplierKey2 = $supplier->unique_key; // used for injection domain @@ -635,11 +636,11 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void $project = Project::factory() ->asSiteSignal($supplierKey2) ->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array 'delivery_days_mask' => 127, - 'delivered_today' => 0, + 'delivered_today' => 0, 'delivered_in_month' => 100, ]); @@ -647,25 +648,25 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( - project: $project, - date: $activeDate, - signalType: 'site', + project: $project, + date: $activeDate, + signalType: 'site', signalIdentifier: $supplierKey2, - dailyLimit: 50, - regions: '{}', + dailyLimit: 50, + regions: '{}', ); $phone = '79165000002'; - $fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); + $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); - $injector = new LeadInjector(); + $injector = new LeadInjector; $injector->site( - domain: $supplierKey2, - phone: $phone, - tag: 'Москва', + domain: $supplierKey2, + phone: $phone, + tag: 'Москва', platform: $supplier->platform, - vid: 9_500_100_001, + vid: 9_500_100_001, ); $deal = DB::connection('pgsql_supplier') @@ -683,7 +684,7 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void expect($charge)->not->toBeNull('FINDING: lead_charges row missing for tier boundary test.'); expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks, - "FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 " . + 'FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 '. "(price=45000 kopecks). Got: {$charge->price_per_lead_kopecks}." ); expect($charge->charge_source)->toBe('rub'); @@ -704,15 +705,15 @@ it('intake: bad secret returns 404', function (): void { tmi_clearIpAllowlist(); $response = $this->postJson('/api/webhook/supplier/THIS-IS-THE-WRONG-SECRET-XXXXX', [ - 'vid' => 999_001, + 'vid' => 999_001, 'project' => 'B2_some-domain.test', - 'phone' => '79161234567', - 'time' => now()->timestamp, - 'tag' => 'Москва', + 'phone' => '79161234567', + 'time' => now()->timestamp, + 'tag' => 'Москва', ]); expect($response->status())->toBe(404, - 'FINDING: Bad webhook secret did not return 404. ' . + 'FINDING: Bad webhook secret did not return 404. '. "Got HTTP {$response->status()}." ); })->group('imitation'); @@ -728,17 +729,17 @@ it('intake: invalid phone (wrong prefix) returns 422', function (): void { tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); - $response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [ - 'vid' => 999_002, + $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ + 'vid' => 999_002, 'project' => 'B2_some-domain.test', - 'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/ - 'time' => now()->timestamp, - 'tag' => 'Москва', + 'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/ + 'time' => now()->timestamp, + 'tag' => 'Москва', ]); expect($response->status())->toBe(422, - 'FINDING: Phone starting with 8 (invalid) did not return 422. ' . - "Got HTTP {$response->status()}. Response: " . $response->content() + 'FINDING: Phone starting with 8 (invalid) did not return 422. '. + "Got HTTP {$response->status()}. Response: ".$response->content() ); })->group('imitation'); @@ -750,16 +751,16 @@ it('intake: too-short phone returns 422', function (): void { tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); - $response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [ - 'vid' => 999_003, + $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ + 'vid' => 999_003, 'project' => 'B2_some-domain.test', - 'phone' => '7916123', // too short — only 7 digits after 7 - 'time' => now()->timestamp, - 'tag' => 'Москва', + 'phone' => '7916123', // too short — only 7 digits after 7 + 'time' => now()->timestamp, + 'tag' => 'Москва', ]); expect($response->status())->toBe(422, - 'FINDING: Short phone did not return 422. ' . + 'FINDING: Short phone did not return 422. '. "Got HTTP {$response->status()}." ); })->group('imitation'); @@ -776,18 +777,18 @@ it('intake: timestamp 48h in the past (beyond -24h window) returns 422', functio $oldTime = now()->subHours(48)->getTimestamp(); // 48h ago — outside ±24h window - $response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [ - 'vid' => 999_004, + $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ + 'vid' => 999_004, 'project' => 'B2_some-domain.test', - 'phone' => '79161234568', - 'time' => $oldTime, - 'tag' => 'Москва', + 'phone' => '79161234568', + 'time' => $oldTime, + 'tag' => 'Москва', ]); expect($response->status())->toBe(422, - 'FINDING: Timestamp 48h in the past did not return 422. ' . - "Got HTTP {$response->status()}. " . - "Controller requires time within ±24h (min=now-24h, max=now+24h)." + 'FINDING: Timestamp 48h in the past did not return 422. '. + "Got HTTP {$response->status()}. ". + 'Controller requires time within ±24h (min=now-24h, max=now+24h).' ); })->group('imitation'); @@ -801,16 +802,16 @@ it('intake: timestamp 48h in the future (beyond +24h window) returns 422', funct $futureTime = now()->addHours(48)->getTimestamp(); // 48h in future - $response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [ - 'vid' => 999_005, + $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ + 'vid' => 999_005, 'project' => 'B2_some-domain.test', - 'phone' => '79161234569', - 'time' => $futureTime, - 'tag' => 'Москва', + 'phone' => '79161234569', + 'time' => $futureTime, + 'tag' => 'Москва', ]); expect($response->status())->toBe(422, - 'FINDING: Timestamp 48h in the future did not return 422. ' . + 'FINDING: Timestamp 48h in the future did not return 422. '. "Got HTTP {$response->status()}." ); })->group('imitation'); @@ -843,18 +844,18 @@ it('intake: rate-limit 600/min per IP — 601st request returns 429', function ( } // Now the 601st HTTP request should see tooManyAttempts = true. - $response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [ - 'vid' => 999_006, + $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ + 'vid' => 999_006, 'project' => 'B2_some-domain.test', - 'phone' => '79161234570', - 'time' => now()->timestamp, - 'tag' => 'Москва', + 'phone' => '79161234570', + 'time' => now()->timestamp, + 'tag' => 'Москва', ]); expect($response->status())->toBe(429, - 'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). ' . - "Got HTTP {$response->status()}. " . - "Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken." + 'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). '. + "Got HTTP {$response->status()}. ". + 'Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken.' ); // Clean up rate limiter state to avoid cross-test pollution. diff --git a/app/tests/Feature/SecurityHeadersTest.php b/app/tests/Feature/SecurityHeadersTest.php index 2a9bb3ac..9e18d457 100644 --- a/app/tests/Feature/SecurityHeadersTest.php +++ b/app/tests/Feature/SecurityHeadersTest.php @@ -12,7 +12,6 @@ declare(strict_types=1); * Этот тест фиксирует консолидацию: приложение само эти заголовки больше НЕ ставит. * Бьём по публичному / (welcome SPA) — auth и БД не нужны. */ - test('приложение НЕ ставит Content-Security-Policy-Report-Only (источник CSP — enforcing nginx)', function () { $this->get('/')->assertHeaderMissing('Content-Security-Policy-Report-Only'); }); diff --git a/app/tests/Feature/Supplier/CsvWebhookRaceTest.php b/app/tests/Feature/Supplier/CsvWebhookRaceTest.php index 8f613436..d2b88c29 100644 --- a/app/tests/Feature/Supplier/CsvWebhookRaceTest.php +++ b/app/tests/Feature/Supplier/CsvWebhookRaceTest.php @@ -15,6 +15,7 @@ use App\Services\LeadRouter; use App\Services\NotificationService; use App\Services\RegionTagResolver; use App\Services\SupplierProjects\SupplierProjectResolver; +use Carbon\Carbon; use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -71,7 +72,7 @@ beforeEach(function (): void { linkProjectToSupplier($this->project, $this->sp); createRoutingSnapshotFromProject($this->project, null, 'site', 'race-csv.ru', 100); - createRoutingSnapshotFromProject($this->project, \Carbon\Carbon::tomorrow('Europe/Moscow')->toDateString(), 'site', 'race-csv.ru', 100); + createRoutingSnapshotFromProject($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString(), 'site', 'race-csv.ru', 100); }); /** diff --git a/app/tests/Support/Imitation/ConditionLevers.php b/app/tests/Support/Imitation/ConditionLevers.php index e653c1a8..e595f27c 100644 --- a/app/tests/Support/Imitation/ConditionLevers.php +++ b/app/tests/Support/Imitation/ConditionLevers.php @@ -118,7 +118,7 @@ final class ConditionLevers public static function setRegions(Project $project, array $codes): void { // PostgreSQL int[] литерал: '{82,83}' или '{}'. - $pgArray = '{' . implode(',', array_map('intval', $codes)) . '}'; + $pgArray = '{'.implode(',', array_map('intval', $codes)).'}'; DB::table('projects') ->where('id', $project->id) diff --git a/app/tests/Support/Imitation/FakeDaDataPhoneClient.php b/app/tests/Support/Imitation/FakeDaDataPhoneClient.php index 71ecccae..b5926b2f 100644 --- a/app/tests/Support/Imitation/FakeDaDataPhoneClient.php +++ b/app/tests/Support/Imitation/FakeDaDataPhoneClient.php @@ -26,7 +26,7 @@ use App\Services\DaData\DaDataPhoneResponse; class FakeDaDataPhoneClient extends DaDataPhoneClient { /** - * @var array phone => response (null = throw DaDataException) + * @var array phone => response (null = throw DaDataException) */ private array $stubs = []; @@ -56,10 +56,10 @@ class FakeDaDataPhoneClient extends DaDataPhoneClient city: null, timezone: null, raw: [ - 'qc' => $qc, + 'qc' => $qc, 'provider' => $provider, - 'region' => $region, - 'phone' => $phone, + 'region' => $region, + 'phone' => $phone, ], ); @@ -80,7 +80,7 @@ class FakeDaDataPhoneClient extends DaDataPhoneClient /** * Возвращает заранее зарегистрированный ответ или бросает DaDataException. * - * @throws DaDataException Если стаб не зарегистрирован или зарегистрирован как throw. + * @throws DaDataException Если стаб не зарегистрирован или зарегистрирован как throw. */ public function cleanPhone(string $phone): DaDataPhoneResponse { diff --git a/app/tests/Support/Imitation/ImitationClientsSeeder.php b/app/tests/Support/Imitation/ImitationClientsSeeder.php index ee67fcaa..ce316e1c 100644 --- a/app/tests/Support/Imitation/ImitationClientsSeeder.php +++ b/app/tests/Support/Imitation/ImitationClientsSeeder.php @@ -52,11 +52,11 @@ final class ImitationClientsSeeder private function seedSingleProjectMatrix(): void { - $signals = ['site', 'call']; + $signals = ['site', 'call']; // regions: empty = all-RF; [82] = Москва; [82, 83] = Москва + СПб - $regions = [[], [82], [82, 83]]; + $regions = [[], [82], [82, 83]]; $dayMasks = [127, 31]; - $limits = [3, 30, 300]; + $limits = [3, 30, 300]; $i = 0; foreach ($signals as $signal) { @@ -65,11 +65,11 @@ final class ImitationClientsSeeder foreach ($limits as $limit) { $i++; $this->makeSingleProjectCell( - index: $i, - signal: $signal, - regions: $regionSet, + index: $i, + signal: $signal, + regions: $regionSet, daysMask: $daysMask, - limit: $limit, + limit: $limit, ); } } @@ -96,7 +96,7 @@ final class ImitationClientsSeeder $uniqueSuffix = Str::random(6); $signalIdentifier = $signal === 'site' ? "imit-{$index}-{$uniqueSuffix}.test" - : '7' . str_pad((string) (9000000000 + $index), 10, '0', STR_PAD_LEFT) . $uniqueSuffix; + : '7'.str_pad((string) (9000000000 + $index), 10, '0', STR_PAD_LEFT).$uniqueSuffix; // Pass regions as a PHP int[] — the PostgresIntArray Eloquent cast // converts it to the PostgreSQL literal '{82,83}' or '{}' in set(). @@ -104,27 +104,27 @@ final class ImitationClientsSeeder ? Project::factory() ->asSiteSignal($signalIdentifier) ->create([ - 'name' => "IMIT-single-{$index}", - 'tenant_id' => $tenant->id, - 'regions' => $regions, + 'name' => "IMIT-single-{$index}", + 'tenant_id' => $tenant->id, + 'regions' => $regions, 'delivery_days_mask' => $daysMask, 'daily_limit_target' => $limit, ]) : Project::factory() ->asCallSignal($signalIdentifier) ->create([ - 'name' => "IMIT-single-{$index}", - 'tenant_id' => $tenant->id, - 'regions' => $regions, + 'name' => "IMIT-single-{$index}", + 'tenant_id' => $tenant->id, + 'regions' => $regions, 'delivery_days_mask' => $daysMask, 'daily_limit_target' => $limit, ]); DB::table('project_supplier_links')->insert([ - 'project_id' => $project->id, + 'project_id' => $project->id, 'supplier_project_id' => $this->sharedSupplier->id, - 'platform' => $this->sharedSupplier->platform, - 'subject_code' => null, + 'platform' => $this->sharedSupplier->platform, + 'subject_code' => null, ]); } @@ -139,7 +139,7 @@ final class ImitationClientsSeeder private function makeSharedSupplierProject(): SupplierProject { return SupplierProject::factory()->create([ - 'platform' => 'B2', + 'platform' => 'B2', 'signal_type' => 'site', ]); } @@ -163,9 +163,9 @@ final class ImitationClientsSeeder User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory() - ->asSiteSignal("g1-{$namePrefix}-" . Str::random(6) . '.test') + ->asSiteSignal("g1-{$namePrefix}-".Str::random(6).'.test') ->create([ - 'name' => "{$namePrefix}-project", + 'name' => "{$namePrefix}-project", 'tenant_id' => $tenant->id, ]); @@ -174,10 +174,10 @@ final class ImitationClientsSeeder foreach ([$supplier1, $supplier2] as $supplier) { DB::table('project_supplier_links')->insert([ - 'project_id' => $project->id, + 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, - 'platform' => $supplier->platform, - 'subject_code' => null, + 'platform' => $supplier->platform, + 'subject_code' => null, ]); } @@ -199,7 +199,7 @@ final class ImitationClientsSeeder $supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']); $projects = []; - $tenants = []; + $tenants = []; foreach ([$overrides1, $overrides2] as $idx => $overrides) { $tenant = Tenant::factory()->create(); @@ -207,18 +207,18 @@ final class ImitationClientsSeeder $tenants[] = $tenant; $project = Project::factory() - ->asSiteSignal("g2-client-{$idx}-" . Str::random(6) . '.test') + ->asSiteSignal("g2-client-{$idx}-".Str::random(6).'.test') ->create(array_merge([ - 'name' => "IMIT-G2-client-{$idx}", + 'name' => "IMIT-G2-client-{$idx}", 'tenant_id' => $tenant->id, ], $overrides)); $projects[] = $project; DB::table('project_supplier_links')->insert([ - 'project_id' => $project->id, + 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, - 'platform' => $supplier->platform, - 'subject_code' => null, + 'platform' => $supplier->platform, + 'subject_code' => null, ]); } @@ -249,31 +249,31 @@ final class ImitationClientsSeeder $projectA = Project::factory() ->asSiteSignal("g4-region-{$regionA}-{$uniqueA}.test") ->create([ - 'name' => "IMIT-G4-region-{$regionA}", + 'name' => "IMIT-G4-region-{$regionA}", 'tenant_id' => $tenant->id, - 'regions' => [$regionA], // PHP int[] — PostgresIntArray cast handles conversion + 'regions' => [$regionA], // PHP int[] — PostgresIntArray cast handles conversion ]); $projectB = Project::factory() ->asSiteSignal("g4-region-{$regionB}-{$uniqueB}.test") ->create([ - 'name' => "IMIT-G4-region-{$regionB}", + 'name' => "IMIT-G4-region-{$regionB}", 'tenant_id' => $tenant->id, - 'regions' => [$regionB], // PHP int[] — PostgresIntArray cast handles conversion + 'regions' => [$regionB], // PHP int[] — PostgresIntArray cast handles conversion ]); foreach ([$projectA, $projectB] as $project) { DB::table('project_supplier_links')->insert([ - 'project_id' => $project->id, + 'project_id' => $project->id, 'supplier_project_id' => $supplier->id, - 'platform' => $supplier->platform, - 'subject_code' => null, + 'platform' => $supplier->platform, + 'subject_code' => null, ]); } return [ 'supplier' => $supplier, - 'tenant' => $tenant, + 'tenant' => $tenant, 'projectA' => $projectA, 'projectB' => $projectB, ]; diff --git a/app/tests/Support/Imitation/ImitationTestCase.php b/app/tests/Support/Imitation/ImitationTestCase.php index 76001078..d9c74453 100644 --- a/app/tests/Support/Imitation/ImitationTestCase.php +++ b/app/tests/Support/Imitation/ImitationTestCase.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Support\Imitation; +use App\Support\RussianRegions; use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; @@ -77,11 +78,11 @@ abstract class ImitationTestCase extends TestCase * Only call this when your specific test scenario exercises the Россвязь * branch of LeadRegionResolver (e.g. DaData degradation tests). * - * @param string $defCode DEF-code prefix (e.g. '999'). - * @param string $from Lower bound of number range (e.g. '0000000'). - * @param string $to Upper bound of number range (e.g. '0099999'). - * @param int $subjectCode Subject code (1..89, порядковый, НЕ ГИБДД). - * Use App\Support\RussianRegions::nameToCode() for lookup. + * @param string $defCode DEF-code prefix (e.g. '999'). + * @param string $from Lower bound of number range (e.g. '0000000'). + * @param string $to Upper bound of number range (e.g. '0099999'). + * @param int $subjectCode Subject code (1..89, порядковый, НЕ ГИБДД). + * Use App\Support\RussianRegions::nameToCode() for lookup. */ protected function seedPhoneRange( int $defCode, @@ -94,25 +95,25 @@ abstract class ImitationTestCase extends TestCase // used non-existent columns (range_from/range_to/region_name) and omitted // import_id, so every Россвязь-branch test that called it failed at runtime. $importId = DB::table('phone_ranges_imports')->insertGetId([ - 'imported_at' => now(), - 'source_url' => 'test://rossvyaz', - 'rows_inserted' => 1, - 'rows_updated' => 0, + 'imported_at' => now(), + 'source_url' => 'test://rossvyaz', + 'rows_inserted' => 1, + 'rows_updated' => 0, 'checksum_sha256' => str_repeat('0', 64), - 'status' => 'completed', - 'completed_at' => now(), + 'status' => 'completed', + 'completed_at' => now(), ]); DB::table('phone_ranges')->insert([ - 'def_code' => $defCode, - 'from_num' => $from, - 'to_num' => $to, - 'operator' => 'test-operator', - 'region' => \App\Support\RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', + 'def_code' => $defCode, + 'from_num' => $from, + 'to_num' => $to, + 'operator' => 'test-operator', + 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', 'region_normalized' => null, - 'subject_code' => $subjectCode, - 'imported_at' => now(), - 'import_id' => $importId, + 'subject_code' => $subjectCode, + 'imported_at' => now(), + 'import_id' => $importId, ]); } } diff --git a/app/tests/Support/Imitation/LeadInjector.php b/app/tests/Support/Imitation/LeadInjector.php index 12491b18..032ca770 100644 --- a/app/tests/Support/Imitation/LeadInjector.php +++ b/app/tests/Support/Imitation/LeadInjector.php @@ -27,12 +27,12 @@ final class LeadInjector /** * Инъектировать заявку с сигналом «сайт». * - * @param string $domain Домен сигнала (например, 'vashinvestor.ru'). - * Составляет identifier в project-поле: "{$platform}_{$domain}". - * @param string $phone Телефон в формате 7XXXXXXXXXX. - * @param string|null $tag Тег региона (например, 'Москва'); может быть null. - * @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное (→ DIRECT). - * @param int|null $vid Внешний ID заявки поставщика. Если null — генерируется уникальный. + * @param string $domain Домен сигнала (например, 'vashinvestor.ru'). + * Составляет identifier в project-поле: "{$platform}_{$domain}". + * @param string $phone Телефон в формате 7XXXXXXXXXX. + * @param string|null $tag Тег региона (например, 'Москва'); может быть null. + * @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное (→ DIRECT). + * @param int|null $vid Внешний ID заявки поставщика. Если null — генерируется уникальный. */ public function site( string $domain, @@ -49,12 +49,12 @@ final class LeadInjector /** * Инъектировать заявку с сигналом «звонок». * - * @param string $number Номер телефона для call-сигнала (7XXXXXXXXXX). - * Составляет identifier в project-поле: "{$platform}_{$number}". - * @param string $phone Телефон звонящего в формате 7XXXXXXXXXX. - * @param string|null $tag Тег региона; может быть null. - * @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное (→ DIRECT). - * @param int|null $vid Внешний ID заявки поставщика. Если null — генерируется уникальный. + * @param string $number Номер телефона для call-сигнала (7XXXXXXXXXX). + * Составляет identifier в project-поле: "{$platform}_{$number}". + * @param string $phone Телефон звонящего в формате 7XXXXXXXXXX. + * @param string|null $tag Тег региона; может быть null. + * @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное (→ DIRECT). + * @param int|null $vid Внешний ID заявки поставщика. Если null — генерируется уникальный. */ public function call( string $number, diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index f132c6e9..911f7234 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -4,6 +4,30 @@ **Файл схемы:** `schema.sql` (текущая версия — v8.41, консолидированная — разворачивает БД с нуля). +## v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications + +Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу +`email_verifications` добавлены поля под 6-значный код подтверждения почты — +самозапись создаёт тенанта в статусе `pending_email_confirm` и подтверждает +почту кодом (механика зеркалит `impersonation_tokens`). + +Спека: `docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md`. +План: `docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md`. +Миграция: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php`. + +**Добавлено:** + +- **`email_verifications.code_hash`** — `VARCHAR(255)`, bcrypt-хеш 6-значного кода + (plain в БД не хранится). `token` остаётся внутренним UUID строки. +- **`email_verifications.failed_attempts`** — `SMALLINT NOT NULL DEFAULT 0`, лимит 5 + неверных вводов (как `impersonation_tokens.failed_attempts`). TTL строки — 15 минут + (`expires_at`). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений. +- **GRANT SELECT, INSERT, UPDATE** на `email_verifications` для 4 ролей (самозапись пишет + через BYPASSRLS — нет tenant-GUC на публичном роуте). +- **`tenants.status` расширен `VARCHAR(20)` → `VARCHAR(30)`** — латентный дефект схемы: CHECK + допускал `'pending_email_confirm'` (21 символ), но колонка была 20 → значение не влезало. + Самозапись (G1/SP1) первой реально ставит этот статус (`ALTER COLUMN status TYPE VARCHAR(30)`). + ## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at) Частичный индекс для ретеншена ПДн удалённых лидов. Команда diff --git a/db/schema.sql b/db/schema.sql index 259f8675..6c4f4a1a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,7 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md) +-- Версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток) + tenants.status расширен VARCHAR(20)→(30) (латентный дефект: CHECK допускал 21-символьное 'pending_email_confirm', колонка была 20). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md) +-- Базовая версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md) -- Базовая версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39)) -- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2) -- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md) @@ -616,7 +617,9 @@ CREATE TABLE email_verifications ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users email VARCHAR(255) NOT NULL, - token VARCHAR(255) UNIQUE NOT NULL, + token VARCHAR(255) UNIQUE NOT NULL, -- внутр. uuid строки; секрет — code_hash + code_hash VARCHAR(255), -- v8.42: bcrypt 6-значного кода самозаписи (G1/SP1) + failed_attempts SMALLINT NOT NULL DEFAULT 0, -- v8.42: лимит 5 неверных вводов expires_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() @@ -635,7 +638,7 @@ CREATE TABLE tenants ( subdomain VARCHAR(63) UNIQUE NOT NULL, -- "client1" organization_name VARCHAR(255) NOT NULL, contact_email VARCHAR(255) NOT NULL, - status VARCHAR(20) DEFAULT 'active' + status VARCHAR(30) DEFAULT 'active' -- v8.42: 20→30, 'pending_email_confirm' = 21 симв. CHECK (status IN ('active','suspended','pending_email_confirm','deleted')), -- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal) timezone VARCHAR(50) DEFAULT 'Europe/Moscow', diff --git a/docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md b/docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md new file mode 100644 index 00000000..175793fe --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md @@ -0,0 +1,1403 @@ +# G1 / SP1 — Самозапись клиента с подтверждением почты (backend) Implementation Plan (v7) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Превратить регистрацию в самозапись: почта+пароль+капча создают новый тенант (`pending_email_confirm`) и владельца, на почту уходит 6-значный код, подтверждение активирует аккаунт, даёт 300 ₽ и выполняет вход. + +**Architecture:** Тонкий `RegistrationController` (register/confirm-email/resend) над `RegistrationService`. Сервис пишет/читает `tenants`/`users`/`email_verifications` через BYPASSRLS-подключение `pgsql_supplier` (на публичных роутах нет tenant-GUC). Одновременные регистрации одного email сериализуются PostgreSQL advisory-локом в транзакции (`pg_advisory_xact_lock(hashtext(email))`) — в схеме нет глобального `UNIQUE(users.email)` (только `UNIQUE(tenant_id,email)`), поэтому без лока TOCTOU-гонка создала бы два тенанта. Код подтверждения хранится как bcrypt-хеш в `email_verifications` (TTL 15 мин, 5 попыток) — зеркало `ImpersonationToken`. Капча — через интерфейс `CaptchaVerifier` (dev-драйвер `NullCaptchaVerifier`); реальный Yandex SmartCaptcha подключается позже. + +**Tech Stack:** PHP 8.3, Laravel 13, PostgreSQL 16 (RLS, 5 ролей, advisory locks), Pest 4, bcmath/Hash, Mailable. + +--- + +## Цель + +Backend самозаписи клиента с подтверждением почты 6-значным кодом: три публичных эндпоинта (`register`, `confirm-email`, `resend-code`), новая схема кода в `email_verifications`, шов капчи, генерация subdomain, стартовый баланс 300 ₽ при активации. Реквизиты/ИНН/гейт проекта и фронтенд — вне этого плана. + +## File Structure + +| Файл | Ответственность | +|---|---| +| `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php` | +`code_hash`, +`failed_attempts` в `email_verifications` (DDL через `pgsql_supplier`, гранты) | +| `db/schema.sql` | каноничная схема: 2 колонки в `email_verifications` + версия шапки v8.42 | +| `db/CHANGELOG_schema.md` | запись v8.42 | +| `app/app/Models/EmailVerification.php` | Eloquent-модель кода (`isUsable`/`isExpired`) | +| `app/app/Services/Captcha/CaptchaVerifier.php` | интерфейс шва капчи | +| `app/app/Services/Captcha/NullCaptchaVerifier.php` | dev/test-драйвер (конфигурируемый pass/fail) | +| `app/config/services.php` | блок `captcha` | +| `app/app/Providers/AppServiceProvider.php` | биндинг `CaptchaVerifier` + лимитер `auth-register` | +| `app/app/Services/Auth/RegistrationException.php` | доменное исключение с `reason`/`attemptsRemaining` | +| `app/app/Services/Auth/RegistrationService.php` | оркестрация register/confirm/resend + advisory-лок + subdomain | +| `app/app/Mail/EmailVerificationCodeMail.php` | письмо с кодом | +| `app/resources/views/emails/email_verification_code.blade.php` | шаблон письма | +| `app/app/Http/Requests/Auth/RegisterRequest.php` | +`captcha_token`, −DB-unique (uniqueness в сервисе) | +| `app/app/Http/Requests/Auth/ConfirmEmailRequest.php` | валидация confirm | +| `app/app/Http/Requests/Auth/ResendCodeRequest.php` | валидация resend | +| `app/app/Http/Controllers/Api/RegistrationController.php` | 3 эндпоинта | +| `app/routes/web.php` | роуты register/confirm-email/resend-code (+throttle) | +| `app/app/Http/Controllers/Api/AuthController.php` | удалить старый `register()` | +| `app/tests/Feature/Auth/RegistrationTest.php` | Pest-критерии D8 | +| `app/tests/Feature/Auth/AuthControllerTest.php` | удалить 3 старых register-теста | + +**TDD под стеной:** стена двигает указатель только на УСПЕШНом Bash → «красный» прогон теста нельзя опечатать как шаг (он завесит план). Поэтому: тест-файл пишется ПЕРВЫМ (Task 2, до реализации — TDD-порядок авторства), реализация — следом, миграция тест-БД и зелёные прогоны Pest — в конце (Task 11). Красная фаза подразумевается (тесты написаны до кода), но не опечатывается. + +--- + +### Task 1: Схема — код в `email_verifications` + +**Files:** +- Create: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php` +- Modify: `db/schema.sql` — ДВА независимых Edit: (A) блок таблицы `email_verifications` (~стр.615-623) и (B) строка версии в шапке (стр.3). Участки далеко разнесены, не пересекаются; объединить в один Edit нельзя (между ними ~600 строк). Применяются последовательно. +- Modify: `db/CHANGELOG_schema.md` + +- [ ] **Step 1.1: Миграция** — `Write` файла: + +```php +statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)'); + $supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS failed_attempts SMALLINT NOT NULL DEFAULT 0'); + + // Самозапись пишет/читает email_verifications через BYPASSRLS-роль (нет tenant-GUC + // на публичном роуте). Гарантируем права на проде (на dev — superuser, no-op). + foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) { + $supplier->statement(<<statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS failed_attempts'); + $supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS code_hash'); + } +}; +``` + +- [ ] **Step 1.2: schema.sql — таблица (Edit A)** — заменить блок: + +```sql +CREATE TABLE email_verifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users + email VARCHAR(255) NOT NULL, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +на: + +```sql +CREATE TABLE email_verifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users + email VARCHAR(255) NOT NULL, + token VARCHAR(255) UNIQUE NOT NULL, -- внутр. uuid строки; секрет — code_hash + code_hash VARCHAR(255), -- v8.42: bcrypt 6-значного кода самозаписи (G1/SP1) + failed_attempts SMALLINT NOT NULL DEFAULT 0, -- v8.42: лимит 5 неверных вводов + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +- [ ] **Step 1.3: schema.sql — версия шапки (Edit B, не пересекается с 1.2)** — заменить строку: + +``` +-- Версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md) +``` + +на две строки: + +``` +-- Версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md) +-- Базовая версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md) +``` + +- [ ] **Step 1.4: CHANGELOG** — заменить: + +``` +## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at) +``` + +на: + +``` +## v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications + +Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу +`email_verifications` добавлены поля под 6-значный код подтверждения почты — +самозапись создаёт тенанта в статусе `pending_email_confirm` и подтверждает +почту кодом (механика зеркалит `impersonation_tokens`). + +Спека: `docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md`. +План: `docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md`. +Миграция: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php`. + +**Добавлено:** + +- **`email_verifications.code_hash`** — `VARCHAR(255)`, bcrypt-хеш 6-значного кода + (plain в БД не хранится). `token` остаётся внутренним UUID строки. +- **`email_verifications.failed_attempts`** — `SMALLINT NOT NULL DEFAULT 0`, лимит 5 + неверных вводов (как `impersonation_tokens.failed_attempts`). TTL строки — 15 минут + (`expires_at`). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений. +- **GRANT SELECT, INSERT, UPDATE** на `email_verifications` для 4 ролей (самозапись пишет + через BYPASSRLS — нет tenant-GUC на публичном роуте). + +## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at) +``` + +--- + +### Task 2: Pest-критерии (пишем тест ПЕРВЫМ — TDD-порядок авторства) + +**Files:** +- Create: `app/tests/Feature/Auth/RegistrationTest.php` + +- [ ] **Step 2.1:** `Write` файла: + +```php + true]); +}); + +function registerPayload(array $over = []): array +{ + return array_merge([ + 'email' => 'newclient@example.ru', + 'password' => 'fresh-pass-123', + 'accept_offer' => true, + 'accept_pdn' => true, + 'captcha_token' => 'tok-123', + ], $over); +} + +test('register создаёт pending-тенанта + неактивного владельца + код, без входа', function () { + $r = $this->postJson('/api/auth/register', registerPayload()); + + $r->assertStatus(201); + $r->assertJsonPath('status', 'pending_email_confirm'); + $r->assertJsonPath('email', 'newclient@example.ru'); + expect($r->json('_dev_plain_code'))->toMatch('/^\d{6}$/'); + + $user = User::where('email', 'newclient@example.ru')->first(); + expect($user)->not->toBeNull(); + expect($user->is_active)->toBeFalse(); + + $tenant = Tenant::find($user->tenant_id); + expect($tenant->status)->toBe('pending_email_confirm'); + expect((float) $tenant->balance_rub)->toBe(0.0); + + // Вход НЕ выполнен. + $this->getJson('/api/auth/me')->assertStatus(401); +}); + +test('register пишет email_verifications через pgsql_supplier (BYPASSRLS)', function () { + $connections = []; + DB::listen(function ($q) use (&$connections) { + if (str_contains($q->sql, 'email_verifications')) { + $connections[] = $q->connectionName; + } + }); + + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + + expect($connections)->not->toBeEmpty(); + expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']); +}); + +test('register отклоняет неверную капчу (422), аккаунт не создаётся', function () { + config(['services.captcha.fake_passes' => false]); + + $this->postJson('/api/auth/register', registerPayload()) + ->assertStatus(422) + ->assertJsonValidationErrors(['captcha_token']); + + expect(User::where('email', 'newclient@example.ru')->exists())->toBeFalse(); +}); + +test('register требует accept_offer, accept_pdn, captcha_token', function () { + $this->postJson('/api/auth/register', registerPayload(['accept_offer' => false])) + ->assertStatus(422)->assertJsonValidationErrors(['accept_offer']); + + $this->postJson('/api/auth/register', registerPayload(['accept_pdn' => false])) + ->assertStatus(422)->assertJsonValidationErrors(['accept_pdn']); + + $payload = registerPayload(); + unset($payload['captcha_token']); + $this->postJson('/api/auth/register', $payload) + ->assertStatus(422)->assertJsonValidationErrors(['captcha_token']); +}); + +test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $code = $reg->json('_dev_plain_code'); + + $r = $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $code, + ]); + + $r->assertOk(); + $r->assertJsonPath('user.email', 'newclient@example.ru'); + $r->assertJsonPath('requires_2fa', false); + + $user = User::where('email', 'newclient@example.ru')->first(); + expect($user->is_active)->toBeTrue(); + + $tenant = Tenant::find($user->tenant_id); + expect($tenant->status)->toBe('active'); + expect((float) $tenant->balance_rub)->toBe(300.0); +}); + +test('confirm неверным кодом → 422 + failed_attempts++', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $real = $reg->json('_dev_plain_code'); + $wrong = $real === '000000' ? '111111' : '000000'; + + $r = $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $wrong, + ]); + + $r->assertStatus(422); + expect($r->json('attempts_remaining'))->toBe(4); +}); + +test('5 неверных кодов инвалидируют запись (даже верный код больше не проходит)', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $real = $reg->json('_dev_plain_code'); + $wrong = $real === '000000' ? '111111' : '000000'; + + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $wrong, + ])->assertStatus(422); + } + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $real, + ])->assertStatus(422)->assertJsonPath('reason', 'too_many_attempts'); +}); + +test('протухший код отклоняется, resend выдаёт рабочий', function () { + $reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $user = User::where('email', 'newclient@example.ru')->first(); + + DB::table('email_verifications')->where('user_id', $user->id) + ->update(['expires_at' => now()->subMinutes(5)]); + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $reg->json('_dev_plain_code'), + ])->assertStatus(422)->assertJsonPath('reason', 'expired'); + + $resend = $this->postJson('/api/auth/resend-code', ['email' => 'newclient@example.ru'])->assertOk(); + $newCode = $resend->json('_dev_plain_code'); + expect($newCode)->toMatch('/^\d{6}$/'); + + $this->postJson('/api/auth/confirm-email', [ + 'email' => 'newclient@example.ru', + 'code' => $newCode, + ])->assertOk(); +}); + +test('повторный register на неподтверждённый email не создаёт второго тенанта', function () { + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + $this->postJson('/api/auth/register', registerPayload())->assertStatus(201); + + expect(User::where('email', 'newclient@example.ru')->count())->toBe(1); + expect(Tenant::where('contact_email', 'newclient@example.ru')->count())->toBe(1); +}); + +test('register на активный email → 422', function () { + $tenant = Tenant::factory()->create(); + User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'active@example.ru', + 'is_active' => true, + ]); + + $this->postJson('/api/auth/register', registerPayload(['email' => 'active@example.ru'])) + ->assertStatus(422)->assertJsonValidationErrors(['email']); +}); + +test('resend на несуществующий email → унифицированный ответ (anti-enumeration)', function () { + $this->postJson('/api/auth/resend-code', ['email' => 'nobody@example.ru']) + ->assertOk() + ->assertJsonMissingPath('_dev_plain_code'); +}); +``` + +--- + +### Task 3: Модель `EmailVerification` + +**Files:** +- Create: `app/app/Models/EmailVerification.php` + +- [ ] **Step 3.1:** `Write`: + +```php + 'integer', + 'expires_at' => 'datetime', + 'verified_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isUsable(): bool + { + return $this->verified_at === null + && $this->failed_attempts < 5 + && ! $this->isExpired(); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} +``` + +--- + +### Task 4: Шов капчи + +**Files:** +- Create: `app/app/Services/Captcha/CaptchaVerifier.php` +- Create: `app/app/Services/Captcha/NullCaptchaVerifier.php` +- Modify: `app/config/services.php` + +- [ ] **Step 4.1: Интерфейс** — `Write`: + +```php + [ + 'login' => env('SUPPLIER_LOGIN'), +``` + +на: + +```php + // Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test). + // Реальный Yandex SmartCaptcha подключается позже (SP3/ops). + 'captcha' => [ + 'driver' => env('CAPTCHA_DRIVER', 'null'), + 'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL), + 'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'), + ], + + 'supplier' => [ + 'login' => env('SUPPLIER_LOGIN'), +``` + +--- + +### Task 5: Биндинг капчи + лимитер `auth-register` + +**Files:** +- Modify: `app/app/Providers/AppServiceProvider.php` — ДВА независимых Edit: (A) добавить биндинг в конец метода `register()`; (B) добавить `'auth-register'` в массив лимитеров в `boot()`. Разные методы класса, не пересекаются; применяются последовательно без конфликта. + +- [ ] **Step 5.1: Биндинг (Edit A — метод register())** — заменить: + +```php + ), + ); + } + + /** + * Bootstrap any application services. + */ +``` + +на: + +```php + ), + ); + + // Шов капчи самозаписи (G1/SP1). SP1 — Null-драйвер; реальный + // Yandex SmartCaptcha встанет сюда позже через match по драйверу. + $this->app->bind( + \App\Services\Captcha\CaptchaVerifier::class, + \App\Services\Captcha\NullCaptchaVerifier::class, + ); + } + + /** + * Bootstrap any application services. + */ +``` + +- [ ] **Step 5.2: Лимитер (Edit B — метод boot(), не пересекается с 5.1)** — заменить: + +```php + foreach (['auth-login', 'auth-2fa', 'auth-password'] as $limiterName) { +``` + +на: + +```php + foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) { +``` + +--- + +### Task 6: Письмо с кодом + +**Files:** +- Create: `app/app/Mail/EmailVerificationCodeMail.php` +- Create: `app/resources/views/emails/email_verification_code.blade.php` + +- [ ] **Step 6.1: Mailable** — `Write`: + +```php +email], + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.email_verification_code', + with: ['code' => $this->code], + ); + } +} +``` + +- [ ] **Step 6.2: Шаблон** — `Write` `app/resources/views/emails/email_verification_code.blade.php`: + +```blade +

Здравствуйте!

+

Ваш код подтверждения регистрации в Лидерре:

+

{{ $code }}

+

Код действует 15 минут. Если вы не регистрировались — просто проигнорируйте это письмо.

+``` + +--- + +### Task 7: Доменное исключение + сервис + +**Files:** +- Create: `app/app/Services/Auth/RegistrationException.php` +- Create: `app/app/Services/Auth/RegistrationService.php` + +- [ ] **Step 7.1: Исключение** — `Write`: + +```php +captcha->verify($captchaToken, $ip)) { + throw new RegistrationException('captcha_failed'); + } + + $email = mb_strtolower(trim($email)); + $conn = DB::connection(self::DB_CONNECTION); + + // Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock). + // Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции. + $issued = $conn->transaction(function () use ($conn, $email, $password) { + $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]); + + $existing = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if ($existing && $existing->is_active) { + throw new RegistrationException('email_taken'); + } + + $user = $existing ?: $this->createPendingTenantOwner($email, $password); + + return $this->createCodeRecord($user); + }); + + $this->sendCode($issued['user']->email, $issued['plain']); + + return [ + 'status' => 'pending_email_confirm', + 'user' => $issued['user'], + 'verification' => $issued['record'], + 'dev_code' => $issued['dev_code'], + ]; + } + + public function confirm(string $email, string $code): User + { + $email = mb_strtolower(trim($email)); + $user = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if (! $user) { + throw new RegistrationException('not_found'); + } + + $record = EmailVerification::on(self::DB_CONNECTION) + ->where('user_id', $user->id) + ->whereNull('verified_at') + ->orderByDesc('id') + ->first(); + + if (! $record || ! $record->isUsable()) { + $reason = $record === null ? 'not_found' + : ($record->isExpired() ? 'expired' : 'too_many_attempts'); + throw new RegistrationException($reason); + } + + if (! Hash::check($code, (string) $record->code_hash)) { + // increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил + // бы failed_attempts и сломал лимит 5 попыток). + $record->increment('failed_attempts'); + throw new RegistrationException( + 'invalid_code', + max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts), + ); + } + + // Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта. + DB::connection(self::DB_CONNECTION)->transaction(function () use ($record, $user): void { + $record->update(['verified_at' => now()]); + $user->update(['is_active' => true]); + Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([ + 'status' => 'active', + 'balance_rub' => self::START_BALANCE_RUB, + ]); + }); + + return $user->fresh(); + } + + /** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */ + public function resend(string $email): ?string + { + $email = mb_strtolower(trim($email)); + $conn = DB::connection(self::DB_CONNECTION); + + $issued = $conn->transaction(function () use ($conn, $email) { + $conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]); + + $user = User::on(self::DB_CONNECTION)->where('email', $email)->first(); + if ($user && ! $user->is_active) { + return $this->createCodeRecord($user); + } + + return null; + }); + + if ($issued === null) { + return null; + } + + $this->sendCode($issued['user']->email, $issued['plain']); + + return $issued['dev_code']; + } + + private function createPendingTenantOwner(string $email, string $password): User + { + $tenant = Tenant::on(self::DB_CONNECTION)->create([ + 'subdomain' => $this->generateSubdomain($email), + 'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты) + 'contact_email' => $email, + 'status' => 'pending_email_confirm', + 'balance_rub' => 0, + 'is_trial' => true, + ]); + + return User::on(self::DB_CONNECTION)->create([ + 'tenant_id' => $tenant->id, + 'email' => $email, + 'password_hash' => Hash::make($password), + 'first_name' => 'Новый', + 'last_name' => 'клиент', + 'is_active' => false, + 'totp_enabled' => false, + ]); + } + + /** + * @return array{user:User, record:EmailVerification, plain:string, dev_code:?string} + */ + private function createCodeRecord(User $user): array + { + // Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными). + EmailVerification::on(self::DB_CONNECTION) + ->where('user_id', $user->id) + ->whereNull('verified_at') + ->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]); + + $plain = (string) random_int(100_000, 999_999); + + $record = EmailVerification::on(self::DB_CONNECTION)->create([ + 'user_id' => $user->id, + 'email' => $user->email, + 'token' => (string) Str::uuid(), + 'code_hash' => Hash::make($plain), + 'failed_attempts' => 0, + 'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES), + ]); + + return [ + 'user' => $user, + 'record' => $record, + 'plain' => $plain, + 'dev_code' => app()->environment('local', 'testing') ? $plain : null, + ]; + } + + private function sendCode(string $email, string $plain): void + { + Mail::to($email)->send(new EmailVerificationCodeMail($plain, $email)); + } + + private function generateSubdomain(string $email): string + { + $base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value(); + if ($base === '') { + $base = 'client'; + } + $base = Str::limit($base, 50, ''); + + $candidate = $base; + $i = 0; + while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) { + $i++; + $candidate = $base.$i; + } + + return Str::limit($candidate, 63, ''); + } +} +``` + +--- + +### Task 8: Form Requests + +**Files:** +- Modify: `app/app/Http/Requests/Auth/RegisterRequest.php` +- Create: `app/app/Http/Requests/Auth/ConfirmEmailRequest.php` +- Create: `app/app/Http/Requests/Auth/ResendCodeRequest.php` + +- [ ] **Step 8.1: RegisterRequest** — `Edit`. Заменить методы `rules()` и `messages()`: + +```php + /** @return array */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], + 'password' => $this->passwordRules(), + 'accept_offer' => ['required', 'accepted'], + 'accept_pdn' => ['required', 'accepted'], + ]; + } + + /** @return array */ + public function messages(): array + { + return array_merge($this->passwordMessages(), [ + 'email.required' => 'Укажите email.', + 'email.email' => 'Email указан некорректно.', + 'email.unique' => 'Аккаунт с таким email уже существует.', + 'accept_offer.accepted' => 'Необходимо принять оферту.', + 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', + ]); + } +``` + +на: + +```php + /** + * @return array + * + * NB: уникальность email НЕ через DB-rule — её решает RegistrationService + * (активный email → 422 email_taken; неподтверждённый → перевыпуск кода). + * Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST). + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => $this->passwordRules(), + 'accept_offer' => ['required', 'accepted'], + 'accept_pdn' => ['required', 'accepted'], + 'captcha_token' => ['required', 'string'], + ]; + } + + /** @return array */ + public function messages(): array + { + return array_merge($this->passwordMessages(), [ + 'email.required' => 'Укажите email.', + 'email.email' => 'Email указан некорректно.', + 'accept_offer.accepted' => 'Необходимо принять оферту.', + 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', + 'captcha_token.required' => 'Подтвердите, что вы не робот.', + ]); + } +``` + +> NB: после этого Edit импорт `use Illuminate\Validation\Rule;` в RegisterRequest становится неиспользуемым — `composer pint` уберёт его перед коммитом (Шаг финализации). + +- [ ] **Step 8.2: ConfirmEmailRequest** — `Write`: + +```php + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'code' => ['required', 'string', 'regex:/^\d{6}$/'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'email.required' => 'Укажите email.', + 'code.required' => 'Укажите код из письма.', + 'code.regex' => 'Код состоит из 6 цифр.', + ]; + } +} +``` + +- [ ] **Step 8.3: ResendCodeRequest** — `Write`: + +```php + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'email.required' => 'Укажите email.', + 'email.email' => 'Email указан некорректно.', + ]; + } +} +``` + +--- + +### Task 9: Контроллер + роуты + +**Files:** +- Create: `app/app/Http/Controllers/Api/RegistrationController.php` +- Modify: `app/routes/web.php` +- Modify: `app/app/Http/Controllers/Api/AuthController.php` + +- [ ] **Step 9.1: RegistrationController** — `Write`: + +```php +register( + $request->string('email')->toString(), + $request->string('password')->toString(), + $request->input('captcha_token'), + $request->ip(), + ); + } catch (RegistrationException $e) { + return $this->registrationError($e); + } + + $payload = [ + 'status' => $result['status'], + 'email' => $result['user']->email, + 'expires_at' => $result['verification']->expires_at->toIso8601String(), + ]; + if ($result['dev_code'] !== null) { + $payload['_dev_plain_code'] = $result['dev_code']; + } + + return response()->json($payload, 201); + } + + public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse + { + try { + $user = $service->confirm( + $request->string('email')->toString(), + $request->string('code')->toString(), + ); + } catch (RegistrationException $e) { + $payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason]; + if ($e->attemptsRemaining !== null) { + $payload['attempts_remaining'] = $e->attemptsRemaining; + } + + return response()->json($payload, 422); + } + + Auth::login($user); + $request->session()->regenerate(); + $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); + + return response()->json([ + 'user' => $this->userResource($user), + 'requires_2fa' => false, + ]); + } + + public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse + { + $devCode = $service->resend($request->string('email')->toString()); + + $payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.']; + if ($devCode !== null) { + $payload['_dev_plain_code'] = $devCode; + } + + return response()->json($payload); + } + + private function registrationError(RegistrationException $e): JsonResponse + { + $map = [ + 'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'], + 'email_taken' => ['email', 'Аккаунт с таким email уже существует.'], + ]; + [$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.']; + + return response()->json([ + 'message' => $message, + 'errors' => [$field => [$message]], + ], 422); + } + + /** @return array */ + private function userResource(User $user): array + { + return [ + 'id' => $user->id, + 'email' => $user->email, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'timezone' => $user->timezone, + 'tenant_id' => $user->tenant_id, + 'totp_enabled' => $user->totp_enabled, + 'last_login_at' => $user->last_login_at, + 'notification_preferences' => $user->notification_preferences, + 'sound_enabled' => $user->sound_enabled, + ]; + } +} +``` + +- [ ] **Step 9.2: Роуты** — `Edit` `app/routes/web.php`. Заменить: + +```php + Route::post('/register', 'App\Http\Controllers\Api\AuthController@register'); +``` + +на: + +```php + Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register') + ->middleware('throttle:auth-register'); + Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail') + ->middleware('throttle:auth-register'); + Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode') + ->middleware('throttle:auth-register'); +``` + +- [ ] **Step 9.3: AuthController** — `Edit` (удалить старый `register()`). Найти и заменить блок на пустую строку: + +```php + public function register(RegisterRequest $request): JsonResponse + { + // На MVP — attach нового user'а к первому tenant'у (для UI-разводки). + // Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли. + $tenant = Tenant::first(); + if (! $tenant) { + return response()->json([ + 'message' => 'Tenants не настроены. Обратитесь к администратору.', + ], 503); + } + + $user = User::create([ + 'tenant_id' => $tenant->id, + 'email' => $request->string('email')->toString(), + 'password_hash' => Hash::make($request->string('password')->toString()), + 'first_name' => 'Новый', + 'last_name' => 'Пользователь', + 'is_active' => true, + 'totp_enabled' => false, + ]); + + Auth::login($user); + $request->session()->regenerate(); + + $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); + + return response()->json([ + 'user' => $this->userResource($user), + 'requires_2fa' => false, + ], 201); + } + +``` + +(Импорты `RegisterRequest` и `Tenant` станут неиспользуемыми — уберёт `composer pint` перед коммитом.) + +--- + +### Task 10: Убрать устаревшие register-тесты из AuthControllerTest + +**Files:** +- Modify: `app/tests/Feature/Auth/AuthControllerTest.php` + +- [ ] **Step 10.1:** `Edit` — удалить 3 теста старого контракта register (они переехали в RegistrationTest с новым контрактом). Заменить блок на пустую строку: + +```php +test('POST /api/auth/register создаёт user + возвращает 201', function () { + $response = $this->postJson('/api/auth/register', [ + 'email' => 'new-signup@example.ru', + 'password' => 'fresh-pass-123', + 'accept_offer' => true, + 'accept_pdn' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonPath('user.email', 'new-signup@example.ru'); + $response->assertJsonPath('requires_2fa', false); + + $user = User::where('email', 'new-signup@example.ru')->first(); + expect($user)->not->toBeNull(); + expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue(); +}); + +test('POST /api/auth/register отвергает существующий email (unique)', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'duplicate@example.ru', + ]); + + $response = $this->postJson('/api/auth/register', [ + 'email' => 'duplicate@example.ru', + 'password' => 'any-password-123', + 'accept_offer' => true, + 'accept_pdn' => true, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['email']); +}); + +test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () { + $base = [ + 'email' => 'no-consent@example.ru', + 'password' => 'fresh-pass-123', + ]; + + // Без оферты. + $this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true])) + ->assertStatus(422) + ->assertJsonValidationErrors(['accept_offer']); + + // Без ПДн. + $this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true])) + ->assertStatus(422) + ->assertJsonValidationErrors(['accept_pdn']); +}); + +``` + +(Register-контракт целиком покрыт `RegistrationTest.php`.) + +--- + +### Task 11: Миграция тест-БД + зелёные прогоны + финализация + +**Files:** — + +- [ ] **Step 11.1: Применить миграцию к тест-БД `liderra_testing`** — `Bash` (опечатанный шаг; одиночная команда с env-префиксом, без цепочек; `migrate` — floor-safe, не `migrate:fresh`): + +Run: `DB_DATABASE=liderra_testing php app/artisan migrate --force` +Expected: `INFO Running migrations.` + `... 2026_06_18_120000_add_code_fields_to_email_verifications ... DONE`. Миграция идемпотентна (`ADD COLUMN IF NOT EXISTS`). + +- [ ] **Step 11.2: Зелёный прогон RegistrationTest** — `Bash`: + +Run: `composer --working-dir=app test -- tests/Feature/Auth/RegistrationTest.php` +Expected: PASS (12 тестов зелёные). + +- [ ] **Step 11.3: Зелёный прогон AuthControllerTest** (регресс — register-тесты убраны) — `Bash`: + +Run: `composer --working-dir=app test -- tests/Feature/Auth/AuthControllerTest.php` +Expected: PASS (login/me/logout без register-блока). + +- [ ] **Step 11.4: Финализация (терминал владельца):** `composer --working-dir=app pint` (уберёт неиспользуемые импорты в RegisterRequest/AuthController) + миграция dev-БД (`php app/artisan migrate --force` с обычным `.env`), затем коммит: + +```powershell +$env:LEFTHOOK="0"; git add app/ db/ docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md; git commit -m "feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом" -m "Co-Authored-By: Claude Opus 4.8 (1M context) "; $env:LEFTHOOK=$null +``` + +--- + +## Self-Review + +**Spec coverage:** D1 register → Task 7/8/9; D2 confirm → Task 7/9; D3 resend → Task 7/9; D4 схема → Task 1 + модель Task 3; D5 капча → Task 4/5; D6 subdomain → `generateSubdomain` Task 7; D7 edge/безопасность (BYPASSRLS, hash, throttle, баланс при активации, advisory-лок против гонки) → Task 1/5/7; D8 критерии → Task 2 (+ регресс Task 10). Все секции покрыты. + +**Placeholder scan:** нет TBD/«позже-доделаем» в коде; Yandex SmartCaptcha — осознанный отложенный драйвер, интерфейс готов. + +**Type consistency:** `RegistrationService` методы (`register`/`confirm`/`resend`/`createPendingTenantOwner`/`createCodeRecord`/`sendCode`/`generateSubdomain`), `RegistrationException(reason, attemptsRemaining)`, `CaptchaVerifier::verify(?string,?string)`, `EmailVerification::isUsable()` — имена совпадают во всех тасках. `createCodeRecord` возвращает `{user,record,plain,dev_code}`; `register`/`resend` используют эти ключи. `_dev_plain_code` единообразно. Reason-коды (`captcha_failed`/`email_taken`/`expired`/`too_many_attempts`/`invalid_code`/`not_found`) согласованы сервис↔контроллер↔тесты. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Write","object":"app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php","ref":"D4"}, + {"op":"Edit","object":"db/schema.sql","ref":"D4"}, + {"op":"Edit","object":"db/schema.sql","ref":"D4"}, + {"op":"Edit","object":"db/CHANGELOG_schema.md","ref":"D4"}, + {"op":"Write","object":"app/tests/Feature/Auth/RegistrationTest.php","ref":"D8"}, + {"op":"Write","object":"app/app/Models/EmailVerification.php","ref":"D4"}, + {"op":"Write","object":"app/app/Services/Captcha/CaptchaVerifier.php","ref":"D5"}, + {"op":"Write","object":"app/app/Services/Captcha/NullCaptchaVerifier.php","ref":"D5"}, + {"op":"Edit","object":"app/config/services.php","ref":"D5"}, + {"op":"Edit","object":"app/app/Providers/AppServiceProvider.php","ref":"D5"}, + {"op":"Edit","object":"app/app/Providers/AppServiceProvider.php","ref":"D7"}, + {"op":"Write","object":"app/app/Mail/EmailVerificationCodeMail.php","ref":"D1"}, + {"op":"Write","object":"app/resources/views/emails/email_verification_code.blade.php","ref":"D1"}, + {"op":"Write","object":"app/app/Services/Auth/RegistrationException.php","ref":"D1"}, + {"op":"Write","object":"app/app/Services/Auth/RegistrationService.php","ref":"D1"}, + {"op":"Edit","object":"app/app/Http/Requests/Auth/RegisterRequest.php","ref":"D1"}, + {"op":"Write","object":"app/app/Http/Requests/Auth/ConfirmEmailRequest.php","ref":"D2"}, + {"op":"Write","object":"app/app/Http/Requests/Auth/ResendCodeRequest.php","ref":"D3"}, + {"op":"Write","object":"app/app/Http/Controllers/Api/RegistrationController.php","ref":"D1"}, + {"op":"Edit","object":"app/routes/web.php","ref":"D1"}, + {"op":"Edit","object":"app/app/Http/Controllers/Api/AuthController.php","ref":"D1"}, + {"op":"Edit","object":"app/tests/Feature/Auth/AuthControllerTest.php","ref":"D8"}, + {"op":"Bash","object":"DB_DATABASE=liderra_testing php app/artisan migrate --force","ref":"D4"}, + {"op":"Bash","object":"composer --working-dir=app test -- tests/Feature/Auth/RegistrationTest.php","ref":"D8"}, + {"op":"Bash","object":"composer --working-dir=app test -- tests/Feature/Auth/AuthControllerTest.php","ref":"D8"} +] +``` + +```verified-context-json +[ + {"id":"D4","kind":"EXTRACTED","ref":"app/app/Models/ImpersonationToken.php","anchor":"failed_attempts < 5"}, + {"id":"D8","kind":"EXTRACTED","ref":"app/tests/Concerns/SharesSupplierPdo.php","anchor":"setReadPdo"}, + {"id":"D7","kind":"EXTRACTED","ref":"app/app/Providers/AppServiceProvider.php","anchor":"auth-login"} +] +``` + +## Переговоры + +### Круг 1 + +- **Капча на повторном register:** проверяется на КАЖДОМ вызове `register` (`captcha->verify` стоит ПЕРВЫМ, до транзакции). Голый `resend-code` — отдельный эндпоинт без капчи, anti-enumeration, свой throttle. +- **Зелёные-только Bash под стеной:** красные TDD-прогоны не опечатываются; тест-файл — первым Write (Task 2), зелёные прогоны — в конце. + +### Круг 2 + +- Явный шаг миграции тест-БД (Step 11.1) перед прогонами. Двойной Edit `schema.sql` (блок таблицы ~615 ↔ строка версии стр.3) и `AppServiceProvider.php` (`register()` ↔ `boot()`) — непересекающиеся участки, последовательно. + +### Круг 3 (гонка дублей в register) + +- `register`/`resend` обёрнуты в транзакцию `pgsql_supplier` с `pg_advisory_xact_lock(hashtext('liderra:self-register:'+email))` первым стейтментом → одновременные регистрации одного email сериализуются, дублей нет (DB-level защита, паттерн `audit_chain_hash`). Глобальный `UNIQUE(users.email)` не ставим — он сломал бы легитимный «один email в нескольких тенантах» (схема допускает `UNIQUE(tenant_id,email)`). Письмо — за транзакцией; в `confirm` инкремент `failed_attempts` вне транзакции, активация — атомарно. + +### Круг 4 + +Возражений по существу не осталось. Контроль исполнения при реализации: проверить снятие advisory-лока на commit/rollback транзакции, отправку письма строго вне транзакции (после commit), инкремент `failed_attempts` вне транзакции (чтобы счётчик пережил 422), а также идемпотентность миграции (`ADD COLUMN IF NOT EXISTS`) и идентичность набора колонок в `db/schema.sql` и фактической тест-БД перед зелёным прогоном. diff --git a/docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md b/docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md new file mode 100644 index 00000000..2835bc54 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md @@ -0,0 +1,121 @@ +# G1 / SP1 — Самозапись клиента с подтверждением почты (backend) + +**Дата:** 18.06.2026. Часть находки **G1** (самозапись клиента), под-проект **SP1** (backend, проверяемо Pest). +**Связанные:** SP2 (реквизиты + гейт первого проекта), SP3 (фронт). Здесь — только SP1. + +## Цель + +Превратить регистрацию из «прицепить нового пользователя к первому существующему тенанту» в настоящую +самозапись: посетитель вводит почту, пароль и проходит капчу → создаётся **новый** тенант в состоянии +ожидания подтверждения почты и его владелец-пользователь → на почту уходит 6-значный код → ввод верного +кода активирует тенанта и пользователя, начисляет стартовый баланс 300 ₽ и выполняет вход. + +Реквизиты юр.лица/ИП/физлица, тяга по ИНН и блокировка создания первого проекта до заполнения реквизитов — +**вне** SP1 (это SP2). Фронтенд — вне SP1 (SP3). Реальный провайдер капчи — вне SP1 (подключается позже). + +## Контракт register {#D1} + +`POST /api/auth/register` (публичный, throttled). Тело: `email`, `password`, `accept_offer`, `accept_pdn`, +`captcha_token`. + +- Валидация: `email` — корректный, ≤255; `password` — текущие правила проекта (`HasPasswordRules`); + `accept_offer`/`accept_pdn` — `accepted`; `captcha_token` — обязателен, строка. +- Капча проверяется через шов `CaptchaVerifier` (см. {#D5}); провал → `422` с ошибкой по полю `captcha_token`. +- Уникальность email: если активный пользователь с таким email уже существует → `422` «Аккаунт с таким + email уже существует». Если существует **неподтверждённый** (тенант в состоянии ожидания подтверждения) → + это путь повторной отправки кода (resend), а не создание дубля: код перегенерируется, тенант/пользователь + не дублируются. +- Успех (новый email): создаётся тенант в состоянии ожидания подтверждения почты с уникальным `subdomain` + (см. {#D6}), `organization_name` = временный плейсхолдер на основе email (уточняется в SP2), `balance_rub` + = 0, пробный режим; создаётся владелец-пользователь, неактивный (`is_active=false`); генерируется код и + пишется строка подтверждения почты (см. {#D4}); код отправляется письмом. +- Вход на этом шаге **не выполняется**. Ответ `201`: состояние «ожидает подтверждения», email, срок + годности кода. На окружениях `local`/`testing` ответ дополнительно содержит plain-код для тестов (как это + уже делает init impersonation-флоу), на prod — нет. + +## Контракт confirm-email {#D2} + +`POST /api/auth/confirm-email` (публичный, throttled). Тело: `email`, `code`. + +- Поиск последней непогашенной строки подтверждения по email через BYPASSRLS-подключение (на публичном + роуте нет tenant-контекста — см. {#D7}). +- Если строки нет / срок истёк / превышены попытки / код не совпал → `422`; при несовпадении — + `failed_attempts++`, на 5-й неудаче строка инвалидируется (требуется resend). +- Успех: помечается `verified_at`; владелец-пользователь становится активным; тенант переходит в активное + состояние; начисляется `balance_rub = 300.00`; выполняется `Auth::login` + регенерация сессии; пишется + лог успешной регистрации. Ответ `200`: пользователь + `requires_2fa: false`. + +## Контракт resend-code {#D3} + +`POST /api/auth/resend-code` (публичный, throttled + cooldown). Тело: `email`. + +- Anti-enumeration: ответ унифицирован независимо от существования аккаунта (как forgot-password флоу). +- Для существующего неподтверждённого аккаунта — старый код инвалидируется, генерируется новый, уходит + письмом. Для активного/несуществующего — ничего не делается, ответ тот же. +- Cooldown между отправками защищает от спама писем. + +## Изменения схемы email_verifications {#D4} + +Таблица `email_verifications` уже существует (id, user_id, email, token UNIQUE, expires_at, verified_at, +created_at). Расширяется под код-флоу: + +- `+ code_hash VARCHAR(255)` — bcrypt 6-значного кода (plain-код в БД не хранится). +- `+ failed_attempts SMALLINT NOT NULL DEFAULT 0` — счётчик неверных вводов; лимит 5 (как у + impersonation-токена). +- `token` сохраняется как внутренний уникальный идентификатор строки (UUID), пользовательский секрет — это + код в `code_hash`. TTL строки — 15 минут (`expires_at`). +- Состояние тенанта при ожидании подтверждения — уже предусмотренное схемой значение статуса + `pending_email_confirm`; новых колонок в `tenants` не требуется. + +Правка `db/schema.sql` сопровождается записью в `db/CHANGELOG_schema.md` и Laravel-миграцией. + +## Капча: шов CaptchaVerifier {#D5} + +Капча в SP1 — это серверный шов, чтобы поток оставался проверяемым Pest без внешних ключей. + +- Интерфейс `CaptchaVerifier` с методом проверки токена (возвращает bool/исход). +- Драйвер по умолчанию для dev/testing — нулевой (`NullCaptchaVerifier`), конфигурируемый на пропуск/провал + через конфиг, чтобы тест мог проверить обе ветки. +- Конфиг `services.captcha` (driver + место под ключи). Реальный драйвер Yandex SmartCaptcha — задокументи- + рованный TODO, подключается позже (SP3/ops), интерфейс под него спроектирован сразу. +- Контроллер/сервис регистрации зовёт только интерфейс, не зная провайдера. + +## Генерация subdomain {#D6} + +`tenants.subdomain` — `UNIQUE NOT NULL`, ≤63. При самозаписи известен только email. + +- База: локальная часть email, приведённая к `[a-z0-9]` (прочее отбрасывается/заменяется). +- Уникальность: при коллизии добавляется короткий суффикс (счётчик/случайный), пока значение свободно. +- Fallback при пустой базе — `client` + случайный суффикс. Длина усечена до 63. + +## Edge-cases и безопасность {#D7} + +- Публичные роуты register/confirm/resend не проходят middleware с tenant-контекстом → запись/чтение + `email_verifications` (RLS-таблица) идёт через BYPASSRLS-подключение `pgsql_supplier`, как уже устроено в + impersonation-контроллере. +- Код 6-значный (`100000..999999`), хранится только в виде хеша; сравнение через `Hash::check`. +- Брошенные неподтверждённые тенанты — уборка вынесена в опциональный follow-up (команда + `auth:prune-unconfirmed`), не входит в ядро SP1. +- Стартовый баланс 300 ₽ начисляется **в момент подтверждения** (активации), не при создании заявки — + неподтверждённый аккаунт денег не получает. +- Throttle на всех трёх роутах + cooldown на resend — против перебора кода и спама писем. + +## Критерии приёмки (Pest) {#D8} + +- register с новым email и валидным captcha_token → тенант в ожидании подтверждения, владелец неактивен, + строка кода создана; на testing виден plain-код; вход не выполнен. +- register с невалидным captcha_token → `422`, тенант/пользователь не создаются. +- confirm верным кодом → тенант активен, пользователь активен, `balance_rub = 300.00`, выполнен вход. +- confirm неверным кодом → `422`, `failed_attempts` увеличился; на 5-й неудаче строка инвалидирована. +- confirm протухшим кодом → `422`; resend выдаёт новый рабочий код. +- повторный register на неподтверждённый email → resend без второго тенанта. +- register на активный email → `422`. +- resend на несуществующий/активный email → унифицированный ответ (anti-enumeration). + +```verified-context-json +[ + {"id":"D2","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/AuthController.php","anchor":"Tenant::first()"}, + {"id":"D4","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"pending_email_confirm"}, + {"id":"D1","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/ImpersonationController.php","anchor":"random_int(100_000, 999_999)"} +] +```