CaiWuXiTong/tests/Unit/EcommerceFinance/UpstreamQueryCacheTest.php

297 lines
11 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Tests\TestCase;
class UpstreamQueryCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('upstreams.snapshots.allow_inline_refresh', true);
config()->set('upstreams.development.direct_read', false);
Cache::setDefaultDriver('array');
Cache::flush();
Carbon::setTestNow('2026-07-22 12:00:00');
UpstreamOdbcConnection::reset();
config()->set('upstreams.snapshots.cache_store', 'array');
config()->set('upstreams.runtime.max_concurrency', 4);
config()->set('upstreams.runtime.slot_wait_ms', 0);
$this->configureSource('source_a');
$this->configureSource('source_b');
}
protected function tearDown(): void
{
Carbon::setTestNow();
Cache::flush();
UpstreamOdbcConnection::reset();
parent::tearDown();
}
public function test_identical_scope_and_parameters_are_isolated_by_source(): void
{
$factory = app(UpstreamQueryCacheFactory::class);
$sourceA = $factory->forSource('source_a');
$sourceB = $factory->forSource('source_b');
$callsA = 0;
$callsB = 0;
$valueA = $sourceA->remember('orders.list', ['page' => 1], function () use (&$callsA): array {
$callsA++;
return ['source' => 'a'];
});
$valueB = $sourceB->remember('orders.list', ['page' => 1], function () use (&$callsB): array {
$callsB++;
return ['source' => 'b'];
});
$this->assertSame('a', $valueA['source']);
$this->assertSame('b', $valueB['source']);
$this->assertSame(1, $callsA);
$this->assertSame(1, $callsB);
$this->assertNotEmpty(Cache::get('source_a:snapshots:active:v2'));
$this->assertNotEmpty(Cache::get('source_b:snapshots:active:v2'));
}
public function test_separate_runtime_instances_reuse_the_same_shared_snapshot(): void
{
$firstRuntime = app(UpstreamQueryCacheFactory::class)->forSource('source_a');
$secondRuntime = app(UpstreamQueryCacheFactory::class)->forSource('source_a');
$calls = 0;
$first = $firstRuntime->remember('orders.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['total' => 8];
});
$second = $secondRuntime->remember('orders.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['total' => 99];
});
$this->assertSame(8, $first['total']);
$this->assertSame($first, $second);
$this->assertSame(1, $calls);
}
public function test_page_cold_miss_only_registers_demand_without_querying_upstream(): void
{
config()->set('upstreams.snapshots.allow_inline_refresh', false);
$calls = 0;
try {
app(UpstreamQueryCacheFactory::class)
->forSource('source_a')
->remember('orders.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['total' => 1];
});
$this->fail('页面冷查询不应直接访问上游数据库。');
} catch (UpstreamSnapshotPendingException $exception) {
$this->assertSame(0, $calls);
$this->assertStringContainsString('后台正在准备', $exception->getMessage());
$this->assertSame(15, $exception->getHeaders()['Retry-After']);
$this->assertNotEmpty(Cache::get('source_a:snapshots:active:v2'));
}
}
public function test_busy_source_does_not_consume_another_sources_slots(): void
{
$factory = app(UpstreamQueryCacheFactory::class);
$sourceA = $factory->forSource('source_a');
$sourceB = $factory->forSource('source_b');
$slotA = Cache::lock('source_a:upstream-slot:0', 30);
$this->assertTrue($slotA->get());
try {
$valueB = $sourceB->remember('orders.list', [], fn (): array => ['total' => 1]);
$this->assertSame(1, $valueB['total']);
try {
$sourceA->remember('orders.list', [], fn (): array => ['total' => 2]);
$this->fail('来源 A 的唯一并发槽被占用时不应继续访问来源 A。');
} catch (ServiceUnavailableHttpException) {
$this->assertTrue(true);
}
} finally {
$slotA->release();
}
}
public function test_scope_allowlists_are_independent_for_each_source(): void
{
$this->configureSource('source_a', ['finance.*']);
$this->configureSource('source_b', ['orders.*']);
$factory = app(UpstreamQueryCacheFactory::class);
$callsA = 0;
try {
$factory->forSource('source_a')->remember('orders.list', [], function () use (&$callsA): array {
$callsA++;
return [];
});
$this->fail('来源 A 未开放 orders 范围,不应调用解析器。');
} catch (ServiceUnavailableHttpException) {
$this->assertSame(0, $callsA);
}
$value = $factory->forSource('source_b')->remember(
'orders.list',
[],
fn (): array => ['allowed' => true]
);
$this->assertTrue($value['allowed']);
}
public function test_local_direct_read_does_not_wait_for_scope_or_shared_snapshot(): void
{
config()->set('upstreams.development.direct_read', true);
$this->configureSource('source_a', []);
$calls = 0;
$cache = app(UpstreamQueryCacheFactory::class)->forSource('source_a');
$first = $cache->remember('new-feature.list', [], function () use (&$calls): array {
return ['call' => ++$calls];
});
$second = $cache->remember('new-feature.list', [], function () use (&$calls): array {
return ['call' => ++$calls];
});
$this->assertSame(1, $first['call']);
$this->assertSame(2, $second['call']);
$this->assertSame('direct', $cache->responseHeaders()['X-Caiwu-Data-Status']);
$this->assertNull(Cache::get('source_a:snapshots:active:v2'));
}
public function test_global_cold_query_limit_protects_the_local_runtime(): void
{
config()->set('upstreams.runtime.max_concurrency', 1);
$globalSlot = Cache::lock('upstream:global-slot:0', 30);
$this->assertTrue($globalSlot->get());
try {
$this->expectException(ServiceUnavailableHttpException::class);
app(UpstreamQueryCacheFactory::class)
->forSource('source_b')
->remember('orders.list', [], fn (): array => ['total' => 1]);
} finally {
$globalSlot->release();
}
}
public function test_unknown_source_is_rejected_before_any_query(): void
{
$this->expectException(InvalidArgumentException::class);
app(UpstreamQueryCacheFactory::class)->forSource('unknown_source');
}
public function test_disabled_source_does_not_attempt_an_odbc_connection(): void
{
config()->set('test_upstream_source_a.enabled', false);
$this->expectException(ServiceUnavailableHttpException::class);
UpstreamOdbcConnection::get('source_a');
}
public function test_disabled_source_does_not_expose_an_existing_snapshot(): void
{
$cache = app(UpstreamQueryCacheFactory::class)->forSource('source_a');
$cache->remember('orders.list', [], fn (): array => ['sensitive' => true]);
config()->set('test_upstream_source_a.enabled', false);
$this->expectException(ServiceUnavailableHttpException::class);
$cache->remember('orders.list', [], fn (): array => ['sensitive' => false]);
}
public function test_non_read_only_connection_intent_is_rejected_before_connecting(): void
{
config()->set('test_upstream_source_a.connection', [
'host' => 'example.invalid',
'port' => 1433,
'database' => 'finance',
'username' => 'reader',
'password' => str_repeat('x', 8),
'odbc_driver' => 'FreeTDS',
'tds_version' => '7.4',
'application_intent' => 'ReadWrite',
]);
$this->expectException(ServiceUnavailableHttpException::class);
$this->expectExceptionMessage('上游数据库只允许只读连接意图。');
UpstreamOdbcConnection::create('source_a');
}
public function test_removed_scope_does_not_expose_an_existing_snapshot(): void
{
$cache = app(UpstreamQueryCacheFactory::class)->forSource('source_a');
$cache->remember('orders.list', [], fn (): array => ['sensitive' => true]);
config()->set('test_upstream_source_a.allowed_scopes', ['finance.*']);
$this->expectException(ServiceUnavailableHttpException::class);
$cache->remember('orders.list', [], fn (): array => ['sensitive' => false]);
}
private function configureSource(string $source, array $allowedScopes = ['*']): void
{
$configKey = 'test_upstream_' . $source;
config()->set('upstreams.sources.' . $source, [
'label' => strtoupper($source),
'config' => $configKey,
]);
config()->set($configKey, [
'enabled' => true,
'allowed_scopes' => $allowedScopes,
'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' => 300,
'refresh_limit' => 2,
'miss_budget_per_window' => 1000,
'budget_window_seconds' => 60,
'lock_seconds' => 15,
'lock_wait_seconds' => 1,
'max_concurrency' => 1,
'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,
],
]);
}
}