Files
portal/app/tests/Feature/Import/HistoricalImportServiceTest.php
T
Дмитрий 6a3593de7a fix(import): final review — tenant-изоляция import_unknown_statuses под BYPASSRLS
Final review нашёл: HistoricalImportService::loadStatusOverrides и
persistUnknownStatuses запрашивали import_unknown_statuses без явного
where(tenant_id), полагаясь на RLS через SET LOCAL. Но queue worker на prod
работает под crm_supplier_worker — BYPASSRLS-роль (00_create_roles.sql §5),
SET LOCAL не фильтрует → cross-tenant утечка: импорт тенанта A мог подхватить
resolved-маппинг тенанта B и инкрементировать его occurrences.

Добавлен явный where(tenant_id) в обе выборки (конвенция defense-in-depth
00_create_roles.sql:64 — WHERE-фильтры обязательны под BYPASSRLS). +тест
cross-tenant изоляции (red-green verified: без фикса 'Архив' тенанта A
получал status 'closed' из чужого маппинга).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:31:56 +03:00

187 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Reminder;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
$this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
$this->user = User::factory()->for($this->tenant)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->service = app(HistoricalImportService::class);
});
function importLog(Tenant $tenant, User $user, bool $dryRun = false): ImportLog
{
return ImportLog::create([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'filename' => 'leads.csv',
'file_path' => 'imports/x.csv',
'dry_run' => $dryRun,
]);
}
function parseFixture(string $body): array
{
$header = "\xEF\xBB\xBF".'id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя';
return (new CsvLeadsParser)->parse($header."\n".$body)->rows;
}
test('импортирует исторические лиды, создавая партиции под старые даты', function (): void {
$rows = parseFixture(
'5001,Окна,окна,79161112233,2023/07/10 10:00:00,,Комментарий,Переговоры,Иван'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect($result->added)->toBe(1)
->and($result->updated)->toBe(0);
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
expect($deal->status)->toBe('negotiations')
->and($deal->phone)->toBe('79161112233')
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
});
test('баланс лидов не списывается, фиксируется транзакция historical_import', function (): void {
$rows = parseFixture(
'5002,Окна,окна,79161112234,2023/07/11 10:00:00,,,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect($this->tenant->fresh()->balance_leads)->toBe(5); // не изменился
$tx = DB::table('balance_transactions')
->where('tenant_id', $this->tenant->id)
->where('type', 'historical_import')
->first();
expect($tx)->not->toBeNull()
->and((int) $tx->amount_leads)->toBe(0);
});
test('повторный импорт того же файла не создаёт дублей (idempotent UPDATE)', function (): void {
$rows = parseFixture(
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Старый,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
$rows2 = parseFixture(
'5003,Окна,окна,79161112235,2023/08/01 10:00:00,,Обновлённый,Оплачено,Пётр'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows2);
expect($result->added)->toBe(0)
->and($result->updated)->toBe(1)
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
->and($deal->contact_name)->toBe('Пётр')
->and($deal->comment)->toBe('Обновлённый');
});
test('непустое «Напоминание» создаёт строку reminders', function (): void {
$rows = parseFixture(
'5004,Окна,окна,79161112236,2023/09/01 10:00:00,2023/09/05 14:00:00,,Новые,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
$deal = Deal::query()->where('source_crm_id', 5004)->firstOrFail();
$reminder = Reminder::query()->where('deal_id', $deal->id)->firstOrFail();
expect($reminder->remind_at->format('Y-m-d H:i'))->toBe('2023-09-05 14:00')
->and($reminder->created_by)->toBe($this->user->id);
});
test('неизвестный статус → сделка new + запись в import_unknown_statuses', function (): void {
$log = importLog($this->tenant, $this->user);
$rows = parseFixture(
"5005,Окна,окна,79161112237,2023/10/01 10:00:00,,,Архив,\n".
'5006,Окна,окна,79161112238,2023/10/02 10:00:00,,,Архив,'
);
$result = $this->service->import($this->tenant->id, $this->user->id, $log, $rows);
expect($result->unknownStatuses)->toBe(['Архив' => 2])
->and(Deal::query()->where('source_crm_id', 5005)->firstOrFail()->status)->toBe('new');
$unknown = ImportUnknownStatus::query()->where('status_ru', 'Архив')->firstOrFail();
expect($unknown->occurrences)->toBe(2)
->and($unknown->mapped_to_slug)->toBeNull();
});
test('resolved-маппинг tenant-а применяется к ранее неизвестному статусу', function (): void {
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id,
'status_ru' => 'Архив',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'resolved_at' => now(),
]);
$rows = parseFixture(
'5007,Окна,окна,79161112239,2023/11/01 10:00:00,,,Архив,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
});
test('dry_run не пишет сделки, но считает проекцию', function (): void {
$rows = parseFixture(
'5008,Окна,окна,79161112240,2023/12/01 10:00:00,,,Новые,'
);
$result = $this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user, dryRun: true), $rows);
expect($result->added)->toBe(1)
->and(Deal::query()->where('source_crm_id', 5008)->exists())->toBeFalse();
});
test('неизвестные статусы и resolved-маппинг изолированы по тенантам', function (): void {
// Тенант B уже резолвил «Архив» → closed и накопил 9 вхождений.
$otherTenant = Tenant::factory()->create();
ImportUnknownStatus::create([
'tenant_id' => $otherTenant->id,
'status_ru' => 'Архив',
'occurrences' => 9,
'mapped_to_slug' => 'closed',
'resolved_at' => now(),
]);
// Тенант A (this) импортирует лид со статусом «Архив». Под BYPASSRLS queue
// worker'ом без явного where(tenant_id) сервис подхватил бы маппинг тенанта B
// и инкрементировал бы его строку — это и проверяется.
$rows = parseFixture(
'6001,Окна,окна,79161119999,2023/06/01 10:00:00,,,Архив,'
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
// Сделка тенанта A — 'new': маппинг тенанта B НЕ применён.
expect(Deal::query()->where('source_crm_id', 6001)->firstOrFail()->status)->toBe('new');
// У тенанта A — собственная запись import_unknown_statuses, occurrences=1.
$ownRow = ImportUnknownStatus::query()
->where('tenant_id', $this->tenant->id)
->where('status_ru', 'Архив')
->firstOrFail();
expect($ownRow->occurrences)->toBe(1);
// Строка тенанта B не тронута (occurrences остался 9).
$otherRow = ImportUnknownStatus::query()
->where('tenant_id', $otherTenant->id)
->where('status_ru', 'Архив')
->firstOrFail();
expect($otherRow->occurrences)->toBe(9);
});