Files
patbond-flutter/test/analytics/analytics_service_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

140 lines
4.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
// 本文件从不 setMockInitialValuesSharedPreferences 走真实
// 平台通道并抛异常,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);
});
});
}