CaiWuXiTong/tests/Feature/Console/BusinessMirrorScheduleTest.php
renshen 23f2e9943c feat: 增加退款每日同步和驾驶舱预警
退款待确认改为每日06:00专属同步,异常状态显示到财务驾驶舱。
2026-11-04 17:46:57 +08:00

237 lines
12 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
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Mockery;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Tests\TestCase;
class BusinessMirrorScheduleTest extends TestCase
{
public function test_daily_dataset_configuration_contains_only_refund_pending_tables(): void
{
$this->assertSame([
'operations_replica.rpt.jst_returnAndRefundInfo',
'operations_replica.rpt.jst_returnAndRefundItem',
'operations_replica.rpt.jst_qmcust_refundlist',
'operations_replica.rpt.jst_qmcust_refundlist_item',
], config('upstream_mirrors.table_sync.daily_datasets'));
}
public function test_stale_source_does_not_block_healthy_datasets_and_reports_failure(): void
{
config([
'upstream_mirrors.refresh_enabled' => true,
'upstream_pipeline.sync.max_datasets' => 2,
]);
$registry = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$sync = Mockery::mock(BusinessMirrorSynchronizer::class);
$objects = [];
foreach (['healthy-a', 'stale-source', 'planting-work-orders'] as $key) {
$objects[$key] = Mockery::mock(BusinessMirrorDataset::class);
if ($key === 'stale-source') {
$registry->shouldReceive('scheduled')->once()->with($key)
->andThrow(new InvalidArgumentException('Unregistered source: private diagnostic'));
$mirror->shouldNotReceive('state')->with($objects[$key]);
continue;
}
$registry->shouldReceive('scheduled')->once()->with($key)->andReturn(true);
$registry->shouldReceive('definitionValue')->with($key, 'full_reconcile_hours', 0)->andReturn(0);
$mirror->shouldReceive('state')->with($objects[$key])->andReturn([
'initial_completed_at' => '2026-10-19 00:00:00',
'last_attempted_at' => '2026-10-25 00:00:00',
'status' => 'succeeded',
]);
$sync->shouldReceive('sync')->once()->with($key, false, null, null)
->ordered()->andReturn(['status' => 'succeeded', 'batches' => 1, 'rows' => 1]);
}
$registry->shouldReceive('all')->once()->andReturn($objects);
Log::shouldReceive('warning')->once()->with('共享业务镜像调度配置无效,已跳过该数据集。', [
'dataset' => 'stale-source',
'exception' => InvalidArgumentException::class,
]);
$this->app->instance(BusinessMirrorDatasetRegistry::class, $registry);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $sync);
$this->artisan('business-mirror:sync-tables')
->expectsOutputToContain('stale-source:调度配置无效,已跳过')
->assertFailed();
}
public function test_unapproved_first_sync_is_skipped(): void
{
config(['upstream_mirrors.refresh_enabled' => true]);
$dataset = Mockery::mock(BusinessMirrorDataset::class);
$registry = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$registry->shouldReceive('all')->once()->andReturn(['pending' => $dataset]);
$registry->shouldReceive('scheduled')->with('pending')->andReturn(true);
$registry->shouldReceive('automaticInitialSyncAllowed')->with('pending')->andReturn(false);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('state')->with($dataset)->andReturn(null);
$sync = Mockery::mock(BusinessMirrorSynchronizer::class);
$sync->shouldNotReceive('sync');
$this->app->instance(BusinessMirrorDatasetRegistry::class, $registry);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $sync);
$this->artisan('business-mirror:sync-tables')->assertSuccessful();
}
public function test_catchup_and_routine_datasets_both_receive_a_turn(): void
{
config(['upstream_mirrors.refresh_enabled' => true, 'upstream_pipeline.sync.max_datasets' => 2]);
$registry = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$registry->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$objects = [];
foreach (['routine-a', 'routine-b', 'catchup-z'] as $key) {
$objects[$key] = Mockery::mock(BusinessMirrorDataset::class);
$registry->shouldReceive('scheduled')->with($key)->andReturn(true);
$mirror->shouldReceive('state')->with($objects[$key])->andReturn([
'initial_completed_at' => '2026-08-01 00:00:00', 'last_attempted_at' => '2026-08-01 00:00:00',
'status' => $key === 'catchup-z' ? 'running' : 'succeeded',
]);
}
$registry->shouldReceive('all')->andReturn($objects);
$sync = Mockery::mock(BusinessMirrorSynchronizer::class);
$result = ['status' => 'succeeded', 'batches' => 1, 'rows' => 1];
$sync->shouldReceive('sync')->once()->with('routine-a', false, null, null)->ordered()->andReturn($result);
$sync->shouldReceive('sync')->once()->with('catchup-z', false, null, 6)->ordered()->andReturn($result);
$this->app->instance(BusinessMirrorDatasetRegistry::class, $registry);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $sync);
$this->artisan('business-mirror:sync-tables')->assertSuccessful();
}
public function test_generic_rotation_excludes_datasets_with_a_dedicated_scheduler(): void
{
config([
'upstream_mirrors.refresh_enabled' => true,
'upstream_mirrors.table_sync.dedicated_datasets' => [
ProductCatalogMirrorRepository::DATASET_KEY,
],
]);
$catalog = Mockery::mock(BusinessMirrorDataset::class);
$other = Mockery::mock(BusinessMirrorDataset::class);
$datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$datasets->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0);
$datasets->shouldReceive('all')->once()->andReturn([
ProductCatalogMirrorRepository::DATASET_KEY => $catalog,
'test.dbo.other' => $other,
]);
$datasets->shouldReceive('scheduled')->once()->with('test.dbo.other')->andReturn(true);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('state')->once()->with($other)->andReturn([
'initial_completed_at' => '2026-08-01 02:00:00',
'last_attempted_at' => '2026-08-15 01:00:00',
]);
$mirror->shouldNotReceive('state')->with($catalog);
$synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class);
$synchronizer->shouldReceive('sync')
->once()
->with('test.dbo.other', false, null, null)
->andReturn([
'status' => 'succeeded',
'mode' => 'incremental',
'batches' => 1,
'rows' => 10,
'dataset' => 'test.dbo.other',
]);
$this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-tables')->assertSuccessful();
}
public function test_generic_rotation_excludes_daily_refund_datasets(): void
{
config([
'upstream_mirrors.refresh_enabled' => true,
'upstream_mirrors.table_sync.daily_datasets' => ['daily-refunds'],
]);
$daily = Mockery::mock(BusinessMirrorDataset::class);
$other = Mockery::mock(BusinessMirrorDataset::class);
$datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$datasets->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0);
$datasets->shouldReceive('all')->once()->andReturn([
'daily-refunds' => $daily,
'test.dbo.other' => $other,
]);
$datasets->shouldReceive('scheduled')->once()->with('test.dbo.other')->andReturn(true);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('state')->once()->with($other)->andReturn([
'initial_completed_at' => '2026-08-01 02:00:00',
'last_attempted_at' => '2026-08-15 01:00:00',
'status' => 'succeeded',
]);
$mirror->shouldNotReceive('state')->with($daily);
$synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class);
$synchronizer->shouldReceive('sync')
->once()
->with('test.dbo.other', false, null, null)
->andReturn(['status' => 'succeeded', 'batches' => 1, 'rows' => 1]);
$this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-tables')->assertSuccessful();
}
public function test_daily_only_processes_every_daily_dataset_until_complete(): void
{
config([
'upstream_mirrors.refresh_enabled' => true,
'upstream_mirrors.table_sync.daily_datasets' => ['daily-a', 'daily-b'],
'upstream_pipeline.sync.max_datasets' => 1,
'upstream_pipeline.sync.cycle_seconds' => 1,
]);
$first = Mockery::mock(BusinessMirrorDataset::class);
$second = Mockery::mock(BusinessMirrorDataset::class);
$other = Mockery::mock(BusinessMirrorDataset::class);
$datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class);
$datasets->shouldReceive('all')->once()->andReturn([
'daily-a' => $first,
'daily-b' => $second,
'other' => $other,
]);
$datasets->shouldReceive('scheduled')->twice()->andReturn(true);
$datasets->shouldReceive('definitionValue')->with(Mockery::type('string'), 'full_reconcile_hours', 0)->andReturn(0);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('state')->twice()->andReturn([
'initial_completed_at' => '2026-08-01 02:00:00',
'last_attempted_at' => '2026-08-15 01:00:00',
'status' => 'succeeded',
]);
$synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class);
$synchronizer->shouldReceive('sync')->twice()->withArgs(
static fn (string $key, bool $full, mixed $batch, int $maxBatches): bool =>
in_array($key, ['daily-a', 'daily-b'], true)
&& $full === false
&& $batch === null
&& $maxBatches === 0
)->andReturn(['status' => 'succeeded', 'batches' => 1, 'rows' => 1]);
$this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets);
$this->app->instance(GenericBusinessMirrorRepository::class, $mirror);
$this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-tables', [
'--daily-only' => true,
'--max-batches' => 0,
])->assertSuccessful();
}
}