import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/analytics/analytics_event_store.dart'; import 'package:patbond_flutter/analytics/analytics_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 服务级持久化队列联动测试(13 号规范 §3.3/§3.4): /// 本地 HttpServer 模拟 /api/v1/events 的 202 / 400 响应。 void main() { Future<(HttpServer, List)> startServer(int statusCode) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final batchSizes = []; server.listen((request) async { final body = jsonDecode(await utf8.decoder.bind(request).join()) as Map; batchSizes.add((body['events'] as List).length); request.response.statusCode = statusCode; await request.response.close(); }); return (server, batchSizes); } AnalyticsService buildService(String apiBaseUrl, AnalyticsEventStore store) { return AnalyticsService( apiBaseUrl: apiBaseUrl, getAccessToken: null, getSessionId: () => 'session-x', store: store, ); } group('AnalyticsService 持久化队列', () { test('满 20 条冲刷、202 受理后清空持久化段', () async { SharedPreferences.setMockInitialValues({}); final (server, batchSizes) = await startServer(202); addTearDown(() => server.close(force: true)); final store = AnalyticsEventStore(); await store.restore(); final service = buildService('http://127.0.0.1:${server.port}', store); for (var i = 0; i < 20; i++) { await service.trackEvent('auth_login_succeeded', {'attemptSeq': i}); } expect(batchSizes, [20]); expect(service.pendingEvents, isEmpty); final prefs = await SharedPreferences.getInstance(); expect( prefs.getKeys().where( (key) => key.startsWith(AnalyticsEventStore.segKeyPrefix), ), isEmpty, ); }); test('flushNow 冲刷不满 20 条的持久化队列', () async { SharedPreferences.setMockInitialValues({}); final (server, batchSizes) = await startServer(202); addTearDown(() => server.close(force: true)); final store = AnalyticsEventStore(); await store.restore(); final service = buildService('http://127.0.0.1:${server.port}', store); await service.trackEvent('auth_login_succeeded'); await service.trackEvent('auth_logout'); await service.flushNow(); expect(batchSizes, [2]); expect(service.pendingEvents, isEmpty); expect(store.length, 0); }); test('上传失败事件持久化,冷启动恢复后自动重传', () async { SharedPreferences.setMockInitialValues({}); // 第一个进程:端口不可达,20 条触发冲刷失败,事件留在持久化队列 final storeA = AnalyticsEventStore(); await storeA.restore(); final serviceA = buildService('http://127.0.0.1:1', storeA); for (var i = 0; i < 20; i++) { await serviceA.trackEvent('auth_login_succeeded', {'attemptSeq': i}); } expect(serviceA.pendingEvents.length, 20); // 模拟冷启动:新实例 restore 恢复并冲刷到可用服务端 final (server, batchSizes) = await startServer(202); addTearDown(() => server.close(force: true)); final storeB = AnalyticsEventStore(); final serviceB = buildService('http://127.0.0.1:${server.port}', storeB); await serviceB.restore(); expect(batchSizes, [20]); expect(serviceB.pendingEvents, isEmpty); }); test('4xx 永久拒绝直接删段丢弃(毒丸防护),计入丢弃诊断数', () async { SharedPreferences.setMockInitialValues({}); final (server, batchSizes) = await startServer(400); addTearDown(() => server.close(force: true)); final store = AnalyticsEventStore(); await store.restore(); final service = buildService('http://127.0.0.1:${server.port}', store); for (var i = 0; i < 20; i++) { await service.trackEvent('auth_login_succeeded', {'attemptSeq': i}); } expect(batchSizes, [20]); expect(service.pendingEvents, isEmpty); expect(store.droppedCount, 20); final prefs = await SharedPreferences.getInstance(); expect( prefs.getKeys().where( (key) => key.startsWith(AnalyticsEventStore.segKeyPrefix), ), isEmpty, ); }); test('积压超过单批上限时按 50 条分批循环上传', () async { SharedPreferences.setMockInitialValues({}); final (server, batchSizes) = await startServer(202); addTearDown(() => server.close(force: true)); final store = AnalyticsEventStore(); await store.restore(); // 直接预置 60 条积压(绕过 trackEvent 的满 20 冲刷触发) for (var i = 0; i < 60; i++) { await store.add({'eventId': 'id-$i', 'eventName': 'page_viewed'}); } final service = buildService('http://127.0.0.1:${server.port}', store); await service.flushNow(); // 段容量 20、单批上限 50 → 40 + 20 两批 expect(batchSizes, [40, 20]); expect(service.pendingEvents, isEmpty); }); test('429 不按毒丸丢弃:保段待退避重试(限流分支随后端落地)', () async { SharedPreferences.setMockInitialValues({}); final (server, batchSizes) = await startServer(429); addTearDown(() => server.close(force: true)); final store = AnalyticsEventStore(); await store.restore(); final service = buildService('http://127.0.0.1:${server.port}', store); for (var i = 0; i < 20; i++) { await service.trackEvent('auth_login_succeeded', {'attemptSeq': i}); } expect(batchSizes, [20]); expect(service.pendingEvents.length, 20); expect(store.droppedCount, 0); }); }); group('AnalyticsService anonymousId 持久化', () { test('首次 restore 落盘生成的 anonymousId', () async { SharedPreferences.setMockInitialValues({}); final service = buildService('http://unused', AnalyticsEventStore()); await service.restore(); final prefs = await SharedPreferences.getInstance(); expect( prefs.getString(AnalyticsService.anonymousIdKey), service.anonymousId, ); }); test('冷启动新实例沿用持久化 anonymousId,事件跨启动可归并', () async { SharedPreferences.setMockInitialValues({}); final serviceA = buildService('http://unused', AnalyticsEventStore()); await serviceA.restore(); final firstLaunchId = serviceA.anonymousId; // 模拟冷启动:新实例构造时是新的随机 v4,restore 后采用存储值。 final serviceB = buildService('http://unused', AnalyticsEventStore()); expect(serviceB.anonymousId, isNot(firstLaunchId)); await serviceB.restore(); expect(serviceB.anonymousId, firstLaunchId); await serviceB.trackEvent('auth_login_succeeded'); expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId); }); test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async { SharedPreferences.setMockInitialValues({ AnalyticsService.anonymousIdKey: 'anon-stored', }); final service = AnalyticsService( apiBaseUrl: 'http://unused', getAccessToken: null, getSessionId: () => 'session-x', anonymousId: 'anon-injected', store: AnalyticsEventStore(), ); await service.restore(); expect(service.anonymousId, 'anon-injected'); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored'); }); }); }