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(); } public function test_daily_only_does_not_enter_general_ding_workflow_sync(): void { config([ 'upstream_mirrors.refresh_enabled' => true, 'upstream_mirrors.table_sync.daily_datasets' => ['daily-a'], ]); $dataset = Mockery::mock(BusinessMirrorDataset::class); $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); $datasets->shouldReceive('all')->once()->andReturn(['daily-a' => $dataset]); $datasets->shouldReceive('scheduled')->once()->with('daily-a')->andReturn(true); $datasets->shouldReceive('definitionValue')->with('daily-a', 'full_reconcile_hours', 0)->andReturn(0); $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); $mirror->shouldReceive('state')->once()->with($dataset)->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') ->once() ->with('daily-a', false, null, 0) ->andReturn(['status' => 'succeeded', 'batches' => 1, 'rows' => 1]); $dingPayable = Mockery::mock(DingPayableWorkflowMirrorSynchronizer::class); $dingPayable->shouldNotReceive('sync'); $dingDetails = Mockery::mock(DingProcessDetailMirrorSynchronizer::class); $dingDetails->shouldNotReceive('sync'); $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); $this->app->instance(DingPayableWorkflowMirrorSynchronizer::class, $dingPayable); $this->app->instance(DingProcessDetailMirrorSynchronizer::class, $dingDetails); $this->app->instance('env', 'production'); $this->artisan('business-mirror:sync-tables', [ '--daily-only' => true, '--max-batches' => 0, ])->assertSuccessful(); } }