Files
portal/app/tests/Feature/Imitation/SeederTest.php
T

223 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Project;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ImitationClientsSeeder;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Schema bootstrap for the liderra_testing DB (idempotent, runs per-test).
*
* ─── WHY THIS EXISTS ────────────────────────────────────────────────────────
* `php artisan migrate:fresh` wraps each migration in a Laravel DB transaction.
* The initial migration calls `Artisan::call('partitions:create-months')` which
* opens a NEW pgsql_supplier connection. A new connection cannot see the
* uncommitted changes of the initial migration's pgsql connection in PostgreSQL
* READ COMMITTED isolation. Result: "table does not exist" when creating partitions.
*
* In addition, MonthlyPartitionManager::PARTITIONED_TABLES includes tables
* (project_routing_snapshots, lead_region_resolution_log) that are NOT in
* schema.sql but in later delta migrations — so partitions:create-months also
* fails because those parent tables don't exist yet.
*
* ─── FIX ─────────────────────────────────────────────────────────────────────
* In beforeEach() (after SharesSupplierPdo has made pgsql_supplier share the
* same PDO as pgsql), we:
* 1. Check if the schema is already loaded (fast no-op if so).
* 2. If not: load schema.sql, then manually create the two delta-migration
* tables that partitions:create-months needs.
* 3. Then call Artisan::call('partitions:create-months') — with shared PDO,
* pgsql_supplier sees our in-transaction DDL.
* 4. Mark remaining delta migrations as "ran" so migrate won't re-run them.
*
* Since DatabaseTransactions rolls back at test end, this is ephemeral.
* If liderra_testing was externally migrated the schema already exists →
* we skip and proceed directly.
*/
beforeEach(function (): void {
// Fast path: if tenants table exists the DB is already set up — skip setup.
$hasTenants = DB::selectOne(
"SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'tenants'"
);
if ($hasTenants === null) {
$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';
if (! is_readable($schemaPath)) {
throw new RuntimeException("schema.sql not found: {$schemaPath}");
}
DB::unprepared((string) file_get_contents($schemaPath));
// Step 2: Fix the webhook_dedup_keys FK that PDO sometimes swallows.
try {
DB::statement(<<<'SQL'
ALTER TABLE webhook_dedup_keys
ADD FOREIGN KEY (deal_id, deal_received_at)
REFERENCES deals (id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED
SQL);
} 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.
// 3a. project_routing_snapshots (delta migration 2026_05_27_120000)
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS project_routing_snapshots (
snapshot_date DATE NOT NULL,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
regions INT[] NOT NULL DEFAULT '{}',
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
signal_identifier TEXT,
sms_senders JSONB,
sms_keyword TEXT,
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (snapshot_date, project_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) PARTITION BY RANGE (snapshot_date)
SQL);
try {
DB::statement(
'CREATE INDEX project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date)'
);
} catch (Throwable) { /* idempotent */ }
try {
DB::statement('ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY');
DB::statement(
"CREATE POLICY project_routing_snapshots_tenant_isolation
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)"
);
} catch (Throwable) { /* idempotent */ }
// 3b. phone_ranges_imports + phone_ranges + lead_region_resolution_log
// (delta migration 2026_05_31_100000)
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
)
SQL);
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code
CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
)
SQL);
DB::unprepared(<<<'SQL'
CREATE TABLE IF NOT EXISTS lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
error TEXT,
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at)
SQL);
try {
DB::statement('ALTER TABLE lead_region_resolution_log ENABLE ROW LEVEL SECURITY');
} catch (Throwable) { /* idempotent */ }
// Step 4: Create month partitions — with shared PDO, pgsql_supplier sees
// all the tables we just created within the same transaction.
Artisan::call('partitions:create-months', ['--ahead' => 2]);
// Step 5: Mark the delta migrations as "ran" so they don't re-run if
// someone calls Artisan::call('migrate') later in the test suite.
$deltaRan = [
'2026_05_27_120000_create_project_routing_snapshots_table',
'2026_05_31_100000_create_phone_ranges_and_resolution_log',
];
foreach ($deltaRan as $migration) {
try {
DB::table('migrations')->updateOrInsert(
['migration' => $migration],
['batch' => 1],
);
} catch (Throwable) { /* ok if migrations table doesn't exist */ }
}
}
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
(new PricingTierSeeder())->run();
// Allow cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Task 4 — ImitationClientsSeeder: single-project matrix (36 rows).
*
* Matrix axes:
* signal ∈ {site, call} — 2
* regions ∈ {[], [82], [82,83]} — 3 (empty=all-RF, [82]=Moscow, [82,83]=Moscow+SPb)
* days ∈ {127 (7 days), 31 (Mon-Fri)} — 2
* limit ∈ {3, 30, 300} — 3
* Total: 2 × 3 × 2 × 3 = 36
*
* All project names are prefixed `IMIT-single-`.
*/
it('seeds the single-project matrix', function (): void {
(new ImitationClientsSeeder())->run();
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
})->group('imitation');