// Author: fruit.com.cn import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/playback_api.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import '../support/fake_http_adapter.dart'; /// 播放票据有有效期(契约 §1.4:签名默认 15 分钟): /// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。 void main() { String envelope(String playUrl) => jsonEncode({ 'code': 0, 'message': 'ok', 'data': { 'episodeId': '11', 'dramaId': '5', 'number': 1, 'title': '第 1 集', 'durationSec': 180, 'accessible': true, 'accessType': 'FREE', 'playUrl': playUrl, 'qualities': ['AUTO'], 'unlockCoin': 0, 'previewSeconds': 0, }, }); _TicketEnv build() { var now = DateTime(2026, 10, 16, 12); final adapter = FakeHttpAdapter( (_) async => FakeHttpResponse( envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'), ), ); final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); final repository = HttpPlaybackRepository( PlaybackApi(client), ttl: const Duration(minutes: 10), clock: () => now, ); return _TicketEnv( repository: repository, adapter: adapter, advance: (Duration d) => now = now.add(d), ); } test('should_reuse_ticket_when_cache_is_still_fresh', () async { final env = build(); await env.repository.ticketFor('11'); env.advance(const Duration(minutes: 9)); await env.repository.ticketFor('11'); expect(env.adapter.requests, hasLength(1)); }); test('should_refetch_ticket_when_cache_expired', () async { final env = build(); await env.repository.ticketFor('11'); env.advance(const Duration(minutes: 11)); final ticket = await env.repository.ticketFor('11'); expect(env.adapter.requests, hasLength(2)); expect(ticket.playUrl, contains('?e=1760000000&s=9f2c')); }); test('should_refetch_ticket_when_explicitly_forced', () async { final env = build(); await env.repository.ticketFor('11'); await env.repository.ticketFor('11', forceRefresh: true); expect(env.adapter.requests, hasLength(2)); }); test('should_report_freshness_when_asking_before_expiry', () async { final env = build(); expect(env.repository.isFresh('11'), isFalse); await env.repository.ticketFor('11'); expect(env.repository.isFresh('11'), isTrue); env.advance(const Duration(minutes: 11)); expect(env.repository.isFresh('11'), isFalse); }); test('should_keep_signed_query_verbatim_when_resolving_media_uri', () { const episode = Episode( id: '11', number: 1, title: '第 1 集', isFree: true, videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c', ); final uri = VideoPlayerPort.resolveUri(episode); // 只补 host,路径与查询串原样保留(签名丢了就等于 403) expect(uri.path, '/files/episode/11.mp4'); expect(uri.query, 'e=1760000000&s=9f2c'); expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c')); }); } /// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。 class _TicketEnv { _TicketEnv({ required this.repository, required this.adapter, required this.advance, }); final HttpPlaybackRepository repository; final FakeHttpAdapter adapter; final void Function(Duration) advance; }