Files
portal/app/tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php
T
Дмитрий 6e5460be5e feat(slepok): Task 2.9 — SyncSupplierProjectsJob reads from snapshot (race 18:02→18:05 closure)
After Stage 2 запуска, 18:05 МСК sync читает project_routing_snapshots за tomorrow
МСК, не live projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync):
клиент мог нажать «пауза» в эти 3 минуты, но мы всё равно докатываем зафиксированный
slepok поставщику (slepok-инвариант).

collectEligibleProjects() переписан с Project::on()->where('is_active', true)
на Project::on()->join('project_routing_snapshots AS snap', ...). Snapshot уже
отфильтрован по is_active/preflight_blocked/frozen_tenant; повторно проверяем
frozen-фильтр на случай freeze в эти 3 минуты. daily_limit_target /
delivery_days_mask / regions переопределяются значениями snapshot (slepok-семантика);
downstream syncGroup() работает без изменений.

Spec §4.2.4b. Closes race 18:02→18:05.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.9

Tests:
- tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php (4 new tests, PASS).
- tests/Feature/Supplier/SyncSupplierProjectsJobTest.php — 12 existing tests patched
  with insertSnapshotForTomorrow($project) helper (12/12 GREEN).
- tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php — 2 existing tests
  patched (2/2 GREEN).
- tests/Pest.php — global helper insertSnapshotForTomorrow().

Combined sync regression: 19/20 PASS + 1 skipped (pre-existing).

Patched via 2 parallel Sonnet subagents per Pravila §15.1; controller-verified
combined regression.
2026-05-28 06:59:09 +03:00

133 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Models\Project;
use App\Models\Tenant;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Helper: вставка snapshot за tomorrow MSK.
*/
function insertTomorrowSnapshot(
Project $project,
string $signalType = 'call',
?string $signalIdentifier = '79161234567',
int $dailyLimit = 10,
int $deliveryDaysMask = 127,
string $regions = '{}',
): void {
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $tomorrow,
'project_id' => $project->id,
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit,
'delivery_days_mask' => $deliveryDaysMask,
'regions' => $regions,
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => $dailyLimit,
'delivered_count' => 0,
'created_at' => now(),
]);
}
it('reads from snapshot for tomorrow, picks up live-paused project (race 18:02→18:05)', function (): void {
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
// ↓ Клиент paus'нул проект между 18:02 (snapshot) и 18:05 (sync).
$project = Project::factory()->for($tenant)->create([
'is_active' => false, // live state — paused
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
]);
insertTomorrowSnapshot($project);
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
// Изолируем от leftover dev data в pgsql_supplier (SharesSupplierPdo шарит PDO,
// но не транзакции — данные между тестами остаются).
$ours = $projects->where('id', $project->id);
expect($ours)->toHaveCount(1);
Carbon::setTestNow();
});
it('skips project that has NO snapshot for tomorrow (live is_active=true ignored)', function (): void {
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true, // live state — active
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
]);
// НЕТ snapshot за tomorrow — sync должен пропустить.
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
$ours = $projects->where('id', $project->id);
expect($ours)->toHaveCount(0);
Carbon::setTestNow();
});
it('overrides daily_limit_target / regions / delivery_days_mask with snapshot values', function (): void {
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 100, // live = 100
'delivery_days_mask' => 127, // live = mon-sun
]);
// Snapshot имеет другой лимит / маску.
insertTomorrowSnapshot(
$project,
dailyLimit: 7, // snapshot = 7
deliveryDaysMask: 31, // mon-fri only
regions: '{77}', // только Москва
);
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
$ours = $projects->where('id', $project->id);
expect($ours)->toHaveCount(1);
$p = $ours->first();
expect((int) $p->daily_limit_target)->toBe(7);
expect((int) $p->delivery_days_mask)->toBe(31);
expect((array) $p->regions)->toBe([77]);
Carbon::setTestNow();
});
it('skips frozen tenants regardless of snapshot presence', function (): void {
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()->subDay()]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
]);
insertTomorrowSnapshot($project);
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
$ours = $projects->where('id', $project->id);
expect($ours)->toHaveCount(0); // frozen tenant — sync должен пропустить
Carbon::setTestNow();
});