artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true]) ->assertSuccessful(); // Staging построен (dry-run не свапает и не дропает staging — данные видны в той же tx). expect(DB::table('phone_ranges_staging')->count())->toBe(3); $r495 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 495'); $r921 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 921'); $r999 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 999'); expect((int) $r495->subject_code)->toBe(82) // Москва ->and((int) $r921->subject_code)->toBe(83) // Санкт-Петербург ->and($r999->subject_code)->toBeNull(); // Атлантида — не маппится // Живой phone_ranges не тронут (свапа не было). expect(DB::table('phone_ranges')->count())->toBe(0); // Журнал импорта: dry-run → rolled_back, несматчившийся регион в error. $imp = DB::table('phone_ranges_imports')->orderByDesc('id')->first(); expect($imp->status)->toBe('rolled_back') ->and($imp->error)->toContain('Атлантида'); }); it('maps all matched rows and counts unmatched separately', function (): void { $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true]) ->assertSuccessful(); $matched = DB::table('phone_ranges_staging')->whereNotNull('subject_code')->count(); $unmatched = DB::table('phone_ranges_staging')->whereNull('subject_code')->count(); expect($matched)->toBe(2)->and($unmatched)->toBe(1); }); it('skips swap when checksum matches a completed import (idempotency)', function (): void { $checksum = hash_file('sha256', rossvyazFixture()); DB::table('phone_ranges_imports')->insert([ 'source_url' => 'https://rossvyaz.gov.ru/prev', 'checksum_sha256' => $checksum, 'status' => 'completed', 'imported_at' => now(), 'completed_at' => now(), ]); // Не dry-run: но checksum совпал с completed → короткое замыкание ДО свапа. $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture()]) ->assertSuccessful(); expect(DB::table('phone_ranges')->count())->toBe(0); // свапа не было $latest = DB::table('phone_ranges_imports')->orderByDesc('id')->first(); expect($latest->status)->toBe('rolled_back'); }); it('force flag bypasses idempotency note even with matching checksum', function (): void { // С --dry-run + --force: идемпотентность игнорируется, но dry-run всё равно не свапает. $checksum = hash_file('sha256', rossvyazFixture()); DB::table('phone_ranges_imports')->insert([ 'source_url' => 'https://rossvyaz.gov.ru/prev', 'checksum_sha256' => $checksum, 'status' => 'completed', 'imported_at' => now(), 'completed_at' => now(), ]); $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true, '--force' => true]) ->assertSuccessful(); // --force обошёл idempotency → staging построен заново (3 строки), но dry-run не свапнул. expect(DB::table('phone_ranges_staging')->count())->toBe(3); expect(DB::table('phone_ranges')->count())->toBe(0); }); it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void { // Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. », // pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская», // и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999). $this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true]) ->assertSuccessful(); $moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495'); $orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922'); $udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987'); $togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902'); expect((int) $moscow->subject_code)->toBe(82) ->and($moscow->region_normalized)->toBe('Москва') ->and((int) $orenburg->subject_code)->toBe(62) ->and($orenburg->region_normalized)->toBe('Оренбургская область') ->and((int) $udmurtia->subject_code)->toBe(21) ->and($udmurtia->region_normalized)->toBe('Удмуртская Республика') ->and($togliatti->subject_code)->toBeNull() ->and($togliatti->region_normalized)->toBeNull(); }); it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void { // После первого atomic-swap исходная id-последовательность уничтожается // (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный // импорт обязан выдать staging.id из собственной последовательности, а не упасть // на NOT NULL. Симулируем это, сняв default у phone_ranges.id. DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT'); $this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true]) ->assertSuccessful(); expect(DB::table('phone_ranges_staging')->count())->toBe(3) ->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0); });