CaiWuXiTong/tests/Feature/SystemSettings/UpstreamSharedDatabaseCacheTest.php
WuYongJie 8f978d522c fix: 隔离单个上游快照刷新失败
单条查询失败时继续刷新同一来源的后续页面,并保留失败状态。
2026-08-03 19:15:19 +08:00

178 lines
7.3 KiB
PHP

<?php
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\Permission;
use Illuminate\Cache\CacheManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamSnapshotRefresherRegistry;
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
use RuntimeException;
use Tests\TestCase;
class UpstreamSharedDatabaseCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('upstreams.stored_configuration.enabled', false);
config()->set('upstreams.local_configuration.enabled', true);
config()->set('upstreams.snapshots.cache_store', 'upstream_shared');
config()->set('upstreams.snapshots.allow_inline_refresh', true);
config()->set('upstreams.runtime.max_concurrency', 4);
config()->set('upstreams.runtime.slot_wait_ms', 0);
config()->set('upstreams.sources.shared_source', [
'label' => '共享来源',
'config' => 'test_shared_source',
]);
config()->set('test_shared_source', [
'enabled' => true,
'allowed_scopes' => ['orders.*'],
'connection' => [
'connect_timeout' => 1,
'query_timeout' => 1,
],
'snapshots' => [
'ttl_seconds' => 60,
'summary_ttl_seconds' => 60,
'options_ttl_seconds' => 3600,
'stale_ttl_seconds' => 3600,
'active_ttl_seconds' => 3600,
'registry_limit' => 50,
'refresh_limit' => 2,
'miss_budget_per_window' => 6,
'budget_window_seconds' => 60,
'lock_seconds' => 15,
'lock_wait_seconds' => 1,
'max_concurrency' => 2,
'slot_wait_ms' => 0,
'failure_cooldown_seconds' => 60,
'query_failure_cooldown_seconds' => 60,
'slow_query_ms' => 5000,
'slow_query_cooldown_seconds' => 60,
'refresh_time_budget_seconds' => 10,
'refresh_pause_ms' => 0,
],
]);
}
public function test_two_runtime_instances_share_one_database_snapshot_without_copying_source_tables(): void
{
$sources = app(UpstreamSourceRegistry::class);
$cacheManager = app(CacheManager::class);
$firstRuntime = new UpstreamQueryCache($sources, 'shared_source', $cacheManager);
$secondRuntime = new UpstreamQueryCache($sources, 'shared_source', $cacheManager);
$calls = 0;
$first = $firstRuntime->remember('orders.list', ['month' => '2026-07'], function () use (&$calls): array {
$calls++;
return ['rows' => [['id' => 1]], 'total' => 1];
});
$second = $secondRuntime->remember('orders.list', ['month' => '2026-07'], function () use (&$calls): array {
$calls++;
return ['rows' => [['id' => 999]], 'total' => 999];
});
$this->assertSame($first, $second);
$this->assertSame(1, $calls);
$this->assertGreaterThan(0, DB::table('upstream_query_cache')->count());
$this->assertTrue(Schema::hasTable('upstream_query_cache'));
$this->assertTrue(Schema::hasTable('upstream_query_locks'));
$this->assertFalse(Schema::hasTable('orders'));
}
public function test_refresh_all_safely_skips_a_registered_but_disabled_source(): void
{
config()->set('jiansun.enabled', false);
$this->artisan('upstream:snapshots:refresh-all')
->expectsOutputToContain('同步未启用,本轮跳过')
->assertExitCode(0);
}
public function test_one_failed_snapshot_does_not_block_the_next_query_for_the_same_source(): void
{
$entries = [
['scope' => 'orders.failed', 'parameters' => []],
['scope' => 'orders.healthy', 'parameters' => []],
];
$sources = Mockery::mock(UpstreamSourceRegistry::class);
$sources->shouldReceive('definition')
->twice()
->with('shared_source')
->andReturn(['key' => 'shared_source', 'label' => '共享来源']);
$sources->shouldReceive('enabled')->once()->with('shared_source')->andReturnTrue();
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldReceive('setting')->andReturnUsing(static fn (string $path, mixed $default = null): mixed => match ($path) {
'snapshots.refresh_limit' => 2,
'snapshots.refresh_time_budget_seconds' => 10,
'snapshots.refresh_pause_ms' => 0,
default => $default,
});
$cache->shouldReceive('activeEntries')->once()->with(2)->andReturn($entries);
$caches = Mockery::mock(UpstreamQueryCacheFactory::class);
$caches->shouldReceive('forSource')->once()->with('shared_source')->andReturn($cache);
$refresher = Mockery::mock(UpstreamSnapshotRefresher::class);
$refresher->shouldReceive('refresh')->once()->with($entries[0])->ordered()
->andThrow(new RuntimeException('first query failed'));
$refresher->shouldReceive('refresh')->once()->with($entries[1])->ordered()->andReturn('fresh');
$refreshers = Mockery::mock(UpstreamSnapshotRefresherRegistry::class);
$refreshers->shouldReceive('forSource')->once()->with('shared_source')->andReturn($refresher);
$this->app->instance(UpstreamSourceRegistry::class, $sources);
$this->app->instance(UpstreamQueryCacheFactory::class, $caches);
$this->app->instance(UpstreamSnapshotRefresherRegistry::class, $refreshers);
$this->artisan('upstream:snapshots:refresh', ['source' => 'shared_source'])
->expectsOutputToContain('失败 1')
->assertExitCode(1);
}
public function test_permission_change_creates_a_separate_snapshot_scope(): void
{
$user = user_model_class()::firstOrFail();
$user->companies()->firstOrFail()->makeCurrent();
$this->actingAs($user);
$sources = app(UpstreamSourceRegistry::class);
$cacheManager = app(CacheManager::class);
$calls = 0;
$before = (new UpstreamQueryCache($sources, 'shared_source', $cacheManager))
->remember('orders.list', [], function () use (&$calls): array {
$calls++;
return ['permission_version' => 1];
});
$permission = Permission::query()->create([
'name' => 'read-shared-snapshot-test',
'display_name' => 'Read Shared Snapshot Test',
'description' => '测试共享快照权限隔离',
]);
$user->attachPermission($permission);
$after = (new UpstreamQueryCache($sources, 'shared_source', $cacheManager))
->remember('orders.list', [], function () use (&$calls): array {
$calls++;
return ['permission_version' => 2];
});
$this->assertSame(1, $before['permission_version']);
$this->assertSame(2, $after['permission_version']);
$this->assertSame(2, $calls);
}
}