223 lines
10 KiB
PHP
223 lines
10 KiB
PHP
<?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');
|