true, 'upstream_mirrors.product_catalog' => [ 'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, 'batch_size' => 1000, 'max_batches_per_run' => 5, 'full_reconcile_hours' => 168, 'reconcile_weekday' => 0, 'reconcile_start_hour' => 2, 'reconcile_window_hours' => 6, ], ]); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } public function test_it_runs_incremental_sync_with_dedicated_limits(): void { Carbon::setTestNow('2026-08-17 10:00:00'); $this->bindDependencies([ 'status' => 'succeeded', 'run_mode' => 'incremental', 'initial_completed_at' => '2026-08-01 02:00:00', 'last_full_succeeded_at' => '2026-08-16 04:00:00', ], false, $this->syncResult('incremental')); $this->artisan('business-mirror:sync-product-catalog') ->expectsOutputToContain('普通商品增量同步:完成') ->assertSuccessful(); } public function test_it_starts_a_due_full_reconcile_inside_the_weekly_window(): void { Carbon::setTestNow('2026-08-23 02:30:00'); $this->bindDependencies([ 'status' => 'succeeded', 'run_mode' => 'incremental', 'initial_completed_at' => '2026-08-01 02:00:00', 'last_full_succeeded_at' => '2026-08-15 02:00:00', ], true, $this->syncResult('full', 'running')); $this->artisan('business-mirror:sync-product-catalog') ->expectsOutputToContain('普通商品全量校准:进行中') ->assertSuccessful(); } public function test_it_continues_an_in_progress_full_reconcile_outside_the_start_window(): void { Carbon::setTestNow('2026-08-24 10:00:00'); $this->bindDependencies([ 'status' => 'running', 'run_mode' => 'full', 'initial_completed_at' => '2026-08-01 02:00:00', 'last_full_succeeded_at' => '2026-08-15 02:00:00', 'cursor' => ['autoid' => 5000], ], true, $this->syncResult('full', 'running')); $this->artisan('business-mirror:sync-product-catalog')->assertSuccessful(); } public function test_it_does_not_automatically_start_the_first_full_sync(): void { Carbon::setTestNow('2026-08-23 02:30:00'); $this->bindDependencies([ 'status' => 'pending', 'initial_completed_at' => null, ], false, $this->syncResult('incremental', 'requires_full_sync')); $this->artisan('business-mirror:sync-product-catalog') ->expectsOutputToContain('需经授权后显式使用 --full') ->assertSuccessful(); } public function test_it_reports_failure_without_claiming_the_old_mirror_was_removed(): void { Carbon::setTestNow('2026-08-17 10:00:00'); $dataset = Mockery::mock(BusinessMirrorDataset::class); $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); $datasets->shouldReceive('get')->once()->with(ProductCatalogMirrorRepository::DATASET_KEY)->andReturn($dataset); $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); $mirror->shouldReceive('state')->once()->with($dataset)->andReturn([ 'status' => 'succeeded', 'initial_completed_at' => '2026-08-01 02:00:00', 'last_full_succeeded_at' => '2026-08-16 04:00:00', ]); $synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class); $synchronizer->shouldReceive('sync')->once()->andThrow(new RuntimeException('upstream_unavailable')); $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); $this->artisan('business-mirror:sync-product-catalog') ->expectsOutputToContain('已保留上一版共享数据') ->assertFailed(); } /** @param array $state */ private function bindDependencies(array $state, bool $full, array $result): void { $dataset = Mockery::mock(BusinessMirrorDataset::class); $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); $datasets->shouldReceive('get')->once()->with(ProductCatalogMirrorRepository::DATASET_KEY)->andReturn($dataset); $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); $mirror->shouldReceive('state')->once()->with($dataset)->andReturn($state); $synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class); $synchronizer->shouldReceive('sync') ->once() ->with(ProductCatalogMirrorRepository::DATASET_KEY, $full, 1000, 5) ->andReturn($result); $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); } /** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */ private function syncResult(string $mode, string $status = 'succeeded'): array { return [ 'status' => $status, 'mode' => $mode, 'batches' => 2, 'rows' => 2000, 'dataset' => ProductCatalogMirrorRepository::DATASET_KEY, ]; } }