CaiWuXiTong/tests/Feature/Console/BusinessMirrorScheduleTest.php
renshen f8e43d4e6e fix: 隔离退款每日同步批次
退款每日批次完成后立即结束,不触发钉钉流程同步。
2026-11-04 18:48:07 +08:00

280 lines
14 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 Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
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();
}
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();
}
}