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

228 lines
10 KiB
PHP
Raw Normal View History

<?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');