4d40c38f06
CI / flutter-gates (push) Successful in 2m15s
- 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>
140 lines
4.6 KiB
Dart
140 lines
4.6 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||
|
||
void main() {
|
||
final uuidV7 = RegExp(
|
||
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||
);
|
||
|
||
AnalyticsService buildService({
|
||
String Function()? getSessionId,
|
||
String? anonymousId,
|
||
}) {
|
||
var counter = 0;
|
||
return AnalyticsService(
|
||
apiBaseUrl: 'http://test',
|
||
getAccessToken: null,
|
||
getSessionId: getSessionId ?? () => 'session-${counter++}',
|
||
anonymousId: anonymousId,
|
||
);
|
||
}
|
||
|
||
group('AnalyticsService', () {
|
||
test('tracks event with required fields', () async {
|
||
final service = buildService(getSessionId: () => 'session-a');
|
||
|
||
await service.trackEvent('auth_login_succeeded', {
|
||
'identifierType': 'username',
|
||
'durationMs': 123,
|
||
});
|
||
|
||
final event = service.pendingEvents.single;
|
||
expect(event['eventName'], 'auth_login_succeeded');
|
||
expect(event['eventVersion'], 1);
|
||
expect(event['sessionId'], 'session-a');
|
||
expect(event['anonymousId'], isNotEmpty);
|
||
expect(event['clientTs'], isNotEmpty);
|
||
expect(event['appVersion'], isNotEmpty);
|
||
expect(event['osVersion'], isNotEmpty);
|
||
expect(event['props'], {'identifierType': 'username', 'durationMs': 123});
|
||
});
|
||
|
||
test('eventId 为 UUIDv7 且逐事件唯一', () async {
|
||
final service = buildService();
|
||
|
||
await service.trackEvent('auth_login_succeeded');
|
||
await service.trackEvent('auth_logout');
|
||
|
||
final ids = service.pendingEvents
|
||
.map((event) => event['eventId'] as String)
|
||
.toList();
|
||
expect(ids[0], matches(uuidV7));
|
||
expect(ids[1], matches(uuidV7));
|
||
expect(ids[0], isNot(ids[1]));
|
||
});
|
||
|
||
test('同一 tracker 下多事件 sessionId 相同,不再每事件生成', () async {
|
||
final service = buildService(getSessionId: () => 'tracker-session');
|
||
|
||
await service.trackEvent('auth_login_succeeded');
|
||
await service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
await service.trackEvent('auth_logout');
|
||
|
||
final sessionIds = service.pendingEvents
|
||
.map((event) => event['sessionId'])
|
||
.toSet();
|
||
expect(sessionIds, {'tracker-session'});
|
||
});
|
||
|
||
test('appVersion 可注入更新(不再硬编码)', () async {
|
||
final service = buildService();
|
||
|
||
service.setAppVersion('2.3.4+56');
|
||
await service.trackEvent('auth_login_succeeded');
|
||
|
||
expect(service.pendingEvents.single['appVersion'], '2.3.4+56');
|
||
});
|
||
|
||
test('rejects events with forbidden field patterns', () async {
|
||
final service = buildService();
|
||
|
||
// Forbidden field triggers local rejection (no throw, silent drop).
|
||
await service.trackEvent('auth_login_succeeded', {
|
||
'userPassword': 'leak', // Forbidden pattern
|
||
});
|
||
|
||
expect(service.pendingEvents, isEmpty);
|
||
});
|
||
|
||
test('identify sets userId on subsequent events', () async {
|
||
final service = buildService();
|
||
|
||
service.identify('user-123');
|
||
await service.trackEvent('auth_login_succeeded');
|
||
|
||
expect(service.pendingEvents.single['userId'], 'user-123');
|
||
});
|
||
|
||
test('reset clears userId but keeps anonymousId', () async {
|
||
final service = buildService(anonymousId: 'anon-123');
|
||
|
||
service.identify('user-123');
|
||
service.reset();
|
||
await service.trackEvent('auth_logout');
|
||
|
||
final event = service.pendingEvents.single;
|
||
expect(event.containsKey('userId'), isFalse);
|
||
expect(event['anonymousId'], 'anon-123');
|
||
});
|
||
|
||
test('上传失败批次重回队列而非整批丢弃', () async {
|
||
// apiBaseUrl 指向不可达端口:满 20 条触发 flush 必然失败。
|
||
final service = AnalyticsService(
|
||
apiBaseUrl: 'http://127.0.0.1:1',
|
||
getAccessToken: null,
|
||
getSessionId: () => 's',
|
||
);
|
||
|
||
for (var i = 0; i < 20; i++) {
|
||
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||
}
|
||
|
||
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
|
||
expect(service.pendingEvents.length, 20);
|
||
});
|
||
|
||
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
|
||
// 本文件从不 setMockInitialValues:SharedPreferences 走真实
|
||
// 平台通道并抛异常,restore 须吞掉并保留构造时的临时 id。
|
||
final service = buildService();
|
||
final ephemeralId = service.anonymousId;
|
||
|
||
await service.restore();
|
||
await service.trackEvent('auth_login_succeeded');
|
||
|
||
expect(service.anonymousId, ephemeralId);
|
||
expect(service.pendingEvents.single['anonymousId'], ephemeralId);
|
||
});
|
||
});
|
||
}
|