Files
patbond-flutter/test/analytics/analytics_persistent_queue_test.dart
T
lixi 4d40c38f06
CI / flutter-gates (push) Successful in 2m15s
新增:埋点队列三项完善——30 秒定时冲刷、失败指数退避、anonymousId 持久化(T3-19)
- 30 秒定时冲刷:AnalyticsService.startPeriodicFlush/stopPeriodicFlush,
  前台期间 Timer.periodic 周期冲刷;SessionTracker 新增 onEnterForeground
  回调,退后台停(并保留既有 flushNow 触发)、回前台恢复,App dispose 收尾。
- 失败退避:网络错误/5xx 后 30s→60s→120s 指数退避封顶 5 分钟,退避窗口
  只挡定时冲刷(flushNow/满 20/冷启动显式触发不受限),上传成功即重置;
  429 改按网络错误同路径保段重试(Retry-After 分支待后端限流落地)。
- anonymousId 持久化:restore 时从 shared_preferences 采用/落盘
  pb.analytics.anonymousId,首次生成后跨冷启动稳定;读取失败降级
  进程内临时 id 不崩溃。
- 测试 272 → 286(+14):fakeAsync 定时/退避 8 个、anonymousId 4 个、
  429 保段 1 个、前后台回调成对 1 个;analyze 0 问题、format 无 diff。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:12:19 +08:00

209 lines
7.7 KiB
Dart

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<int>)> startServer(int statusCode) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final batchSizes = <int>[];
server.listen((request) async {
final body =
jsonDecode(await utf8.decoder.bind(request).join())
as Map<String, dynamic>;
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');
});
});
}