- AnalyticsEventStore:shared_preferences 分段存储(每段 ≤20 条、 总上限 500 超限丢最旧整段)、冷启动恢复、损坏段/损坏索引容错、 droppedCount 丢弃诊断计数 - AnalyticsService 接入持久化队列:上传拿到终态(202/4xx)才删段 实现 at-least-once;冲刷按段拼批 ≤50 条循环上传(契约单批上限); 取批即封段,冲刷在途新事件写入新开放段不丢 - app.dart 冷启动 restore() 恢复离线积压并冲刷(13 号 §3.4 触发点) - 保留第一波语义:flushNow()、4xx 毒丸丢弃、满 20 条冲刷触发 - 新增 13 个单测(恢复/上限淘汰/损坏容错/202 清段/flushNow 协同/ 分批上传),全套 64 测试全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user