set('upstreams.snapshots.allow_inline_refresh', true); Cache::setDefaultDriver('array'); Cache::flush(); Carbon::setTestNow('2026-07-22 10:00:00'); config()->set('upstreams.snapshots.cache_store', 'array'); config()->set('jiansun.snapshots.ttl_seconds', 60); config()->set('jiansun.snapshots.summary_ttl_seconds', 60); config()->set('jiansun.snapshots.options_ttl_seconds', 3600); config()->set('jiansun.snapshots.stale_ttl_seconds', 3600); config()->set('jiansun.snapshots.active_ttl_seconds', 3600); config()->set('jiansun.snapshots.registry_limit', 300); config()->set('jiansun.snapshots.refresh_limit', 2); config()->set('jiansun.snapshots.miss_budget_per_window', 1000); config()->set('jiansun.snapshots.budget_window_seconds', 60); config()->set('jiansun.snapshots.max_concurrency', 2); config()->set('jiansun.snapshots.slot_wait_ms', 0); config()->set('jiansun.snapshots.lock_wait_seconds', 1); config()->set('jiansun.snapshots.slow_query_ms', 5000); config()->set('jiansun.enabled', true); config()->set('jiansun.allowed_scopes', ['*']); } protected function tearDown(): void { Carbon::setTestNow(); Cache::flush(); parent::tearDown(); } public function test_identical_queries_are_shared_without_user_count_limit(): void { $cache = app(JianSunQueryCache::class); $calls = 0; for ($i = 0; $i < 100; $i++) { $value = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array { $calls++; return ['data' => [['id' => 1]], 'total' => 1]; }); $this->assertSame(1, $value['total']); } $this->assertSame(1, $calls); $this->assertNotEmpty(Cache::get('jiansun:snapshots:active:v2')); } public function test_expired_snapshot_returns_immediately_and_is_refreshed_later(): void { $cache = app(JianSunQueryCache::class); $calls = 0; $first = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array { $calls++; return ['data' => [['version' => 1]], 'total' => 1]; }); Carbon::setTestNow(now()->addSeconds(61)); $stale = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array { $calls++; return ['data' => [['version' => 999]], 'total' => 1]; }); $this->assertSame($first, $stale); $this->assertSame(1, $calls, '页面读取过期快照时不应同步访问上游。'); $this->assertSame('stale', $cache->responseHeaders()['X-Caiwu-Data-Status']); $this->assertTrue((bool) ($cache->activeEntries(1)[0]['urgent'] ?? false)); $status = $cache->refresh('goods.list', ['page' => 1], function () use (&$calls): array { $calls++; return ['data' => [['version' => 2]], 'total' => 1]; }); $fresh = $cache->remember('goods.list', ['page' => 1], fn (): array => []); $this->assertSame('fresh', $status); $this->assertSame(2, $fresh['data'][0]['version']); $this->assertSame('fresh', $cache->responseHeaders()['X-Caiwu-Data-Status']); $this->assertSame(2, $calls); } public function test_unrelated_cold_queries_do_not_share_one_global_query_lock(): void { $cache = app(JianSunQueryCache::class); $parametersA = [ 'page' => 1, '__caiwu_snapshot_context' => [ 'company_id' => 0, 'access_hash' => 'system', ], ]; ksort($parametersA); $queryA = hash('sha256', 'goods.list|' . json_encode($parametersA, JSON_UNESCAPED_UNICODE)); $lockA = Cache::lock('jiansun:query-lock:' . $queryA, 30); $this->assertTrue($lockA->get()); try { $value = $cache->remember('goods.summary', [], fn (): array => ['total' => 7]); $this->assertSame(7, $value['total']); } finally { $lockA->release(); } } public function test_upstream_slots_bound_parallel_cold_queries(): void { $cache = app(JianSunQueryCache::class); $slot0 = Cache::lock('jiansun:upstream-slot:0', 30); $slot1 = Cache::lock('jiansun:upstream-slot:1', 30); $this->assertTrue($slot0->get()); $this->assertTrue($slot1->get()); try { $this->expectException(ServiceUnavailableHttpException::class); $cache->remember('goods.list', ['page' => 2], fn (): array => ['data' => [], 'total' => 0]); } finally { $slot0->release(); $slot1->release(); } } public function test_disallowed_cold_scope_never_calls_upstream(): void { config()->set('jiansun.allowed_scopes', ['invoice-deductions.*']); $cache = app(JianSunQueryCache::class); $calls = 0; try { $cache->remember('goods.list', [], function () use (&$calls): array { $calls++; return []; }); $this->fail('未开放的数据范围不应进入冷查询。'); } catch (ServiceUnavailableHttpException) { $this->assertSame(0, $calls); } } public function test_oldest_urgent_snapshot_is_not_starved_by_newer_list_queries(): void { config()->set('upstreams.snapshots.allow_inline_refresh', false); $cache = app(JianSunQueryCache::class); $this->registerColdQuery($cache, 'goods.options'); $registry = Cache::get('jiansun:snapshots:active:v2'); $oldestKey = array_key_first($registry); $registry[$oldestKey]['seen_at'] = time() - 30; Cache::forever('jiansun:snapshots:active:v2', $registry); $this->registerColdQuery($cache, 'invoice-deductions.list'); $this->registerColdQuery($cache, 'invoice-deductions.summary'); $entries = $cache->activeEntries(2); $this->assertSame('goods.options', $entries[0]['scope']); $this->assertContains( $entries[1]['scope'], ['invoice-deductions.list', 'invoice-deductions.summary'] ); } private function registerColdQuery(JianSunQueryCache $cache, string $scope): void { try { $cache->remember($scope, [], fn (): array => []); $this->fail('关闭页面内刷新时,冷查询应登记后返回暂不可用。'); } catch (ServiceUnavailableHttpException) { $this->assertTrue(true); } } }