yx_tracking_flutter/test/analytics_core_test.dart

857 lines
26 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 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
import 'package:yx_tracking_flutter/src/core/interceptors.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
class MemoryEventStorage implements EventStorage {
int _nextId = 1;
final Map<int, StoredEvent> _store = <int, StoredEvent>{};
final List<Event> insertedEvents = <Event>[];
@override
Future<void> init() async {}
@override
Future<int> insert(Event event) async {
final id = _nextId++;
insertedEvents.add(event);
_store[id] = StoredEvent(
id: id,
event: event,
retryCount: event.retryCount,
createTime: event.createTime,
);
return id;
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
final items = _store.values.toList()
..sort((a, b) => a.createTime.compareTo(b.createTime));
return items.take(limit).toList(growable: false);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
final items = _store.values.toList()
..sort((a, b) => b.createTime.compareTo(a.createTime));
return items.take(limit).toList(growable: false);
}
@override
Future<void> deleteByIds(List<int> ids) async {
for (final id in ids) {
_store.remove(id);
}
}
@override
Future<int> count() async => _store.length;
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0 || _store.length <= maxSize) {
return 0;
}
final items = _store.values.toList()
..sort((a, b) => a.createTime.compareTo(b.createTime));
final overflow = items.length - maxSize;
for (var i = 0; i < overflow; i++) {
_store.remove(items[i].id);
}
return overflow;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final current = _store[id];
if (current == null) {
return;
}
final updatedEvent = current.event.copyWith(retryCount: retryCount);
_store[id] = current.copyWith(
retryCount: retryCount,
event: updatedEvent,
);
}
@override
Future<void> dispose() async {}
StoredEvent? byId(int id) => _store[id];
StoredEvent? get singleOrNull {
if (_store.length != 1) {
return null;
}
return _store.values.first;
}
}
class FakeApiClient extends ApiClient {
FakeApiClient(super.config);
ApiException? exceptionToThrow;
final List<List<Event>> sentBatches = <List<Event>>[];
@override
Future<void> sendBatch(List<Event> events) async {
sentBatches.add(events);
final exception = exceptionToThrow;
if (exception != null) {
throw exception;
}
}
}
class TestConfigManager extends ConfigManager {
SystemDimInfo? _currentConfig;
TestConfigManager({
required super.config,
SystemDimInfo? initialConfig,
}) : _currentConfig = initialConfig,
super(refreshInterval: Duration.zero);
@override
SystemDimInfo? get currentConfig => _currentConfig;
void setCurrentConfig(SystemDimInfo? value) {
_currentConfig = value;
}
@override
Future<void> init() async {}
@override
Future<void> fetchAndCacheConfig({bool force = false}) async {}
@override
Future<void> forceRefresh() async {}
@override
Future<void> dispose() async {}
}
class NoopScheduler extends Scheduler {
NoopScheduler({
required super.interval,
required super.onTick,
});
@override
void start() {}
@override
void stop() {}
}
class RecordingInterceptor extends AnalyticsInterceptor {
RecordingInterceptor({
this.onBefore,
this.onAfter,
});
final FutureOr<Event?> Function(Event event)? onBefore;
final FutureOr<void> Function(Event event, SendResult result)? onAfter;
int beforeCalls = 0;
int afterCalls = 0;
final List<SendResult> afterResults = <SendResult>[];
@override
FutureOr<Event?> beforeSend(Event event) async {
beforeCalls += 1;
final handler = onBefore;
if (handler == null) {
return event;
}
return handler(event);
}
@override
FutureOr<void> afterSend(Event event, SendResult result) async {
afterCalls += 1;
afterResults.add(result);
final handler = onAfter;
if (handler == null) {
return;
}
await handler(event, result);
}
}
const DeviceInfo _testDeviceInfo = DeviceInfo(
os: 'test-os',
model: 'test-model',
screenResolution: '100x200',
);
AnalyticsConfig _testConfig({
bool enableDebug = true,
int batchSize = 20,
int maxRetryCount = 2,
int maxCacheSize = 5000,
}) {
return AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com/api/ExternalEventlogs',
clientType: 3,
enableDebug: enableDebug,
batchSize: batchSize,
flushInterval: 3600,
maxRetryCount: maxRetryCount,
maxCacheSize: maxCacheSize,
enableMetrics: false,
);
}
SystemDimInfo _dimInfo({
List<EventDefinition> events = const <EventDefinition>[],
List<TagDefinition> tags = const <TagDefinition>[],
SdkStrategy? strategy,
}) {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, dynamic>{}),
eventDefinitions: events,
tagDefinitions: tags,
sdkStrategy: strategy,
lastFetchedAt: DateTime.now(),
);
}
Future<void> _drainMicrotasks() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
void main() {
group('AnalyticsCore.flush', () {
test('成功发送后删除事件', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final config = _testConfig(batchSize: 5);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_A'),
]),
);
await core.track('EVENT_A');
await core.track('EVENT_A');
expect(await storage.count(), 2);
await core.flush(force: true);
expect(apiClient.sentBatches, isNotEmpty);
expect(await storage.count(), 0);
await core.dispose();
});
test('可重试失败会增加 retryCount 并保留事件', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final config =
_testConfig(enableDebug: true, batchSize: 10, maxRetryCount: 3);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_RETRY'),
]),
);
apiClient.exceptionToThrow = const ApiException(
message: 'network',
retryable: true,
);
await core.track('EVENT_RETRY');
await core.flush(force: true);
final stored = storage.singleOrNull;
expect(stored, isNotNull);
expect(stored!.retryCount, 1);
apiClient.exceptionToThrow = null;
await core.flush(force: true);
expect(await storage.count(), 0);
await core.dispose();
});
test('不可重试失败会直接删除事件', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final config = _testConfig(enableDebug: true, batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_DROP'),
]),
);
apiClient.exceptionToThrow = const ApiException(
message: 'bad request',
retryable: false,
statusCode: 400,
);
await core.track('EVENT_DROP');
await core.flush(force: true);
expect(await storage.count(), 0);
await core.dispose();
});
});
group('AnalyticsCore.validation (release)', () {
test('release 模式会写入校验标记', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final config = _testConfig(enableDebug: false, batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'KNOWN_EVENT'),
],
tags: const <TagDefinition>[
TagDefinition(
tagName: 'requiredTag',
tagType: 'string',
isRequired: true,
),
],
),
);
// 触发两个校验问题:未知事件 + 缺少必填 tag。
await core.track('UNKNOWN_EVENT');
await _drainMicrotasks();
final inserted = storage.insertedEvents.last;
final tags = inserted.customTags ?? const <String, dynamic>{};
expect(tags['_sdk_invalid_event'], true);
expect(tags['_sdk_missing_tags'], contains('requiredTag'));
await core.dispose();
});
});
group('AnalyticsCore.fallback', () {
test('无配置时仍可 track/flush降级为 Phase 1 行为)', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) => TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(_testConfig(enableDebug: true, batchSize: 10));
await core.track('EVENT_NO_CONFIG');
await core.flush(force: true);
expect(apiClient.sentBatches, isNotEmpty);
await core.dispose();
});
});
group('AnalyticsCore.strategy', () {
test('全局策略关闭时直接丢弃事件', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final config = _testConfig(enableDebug: true, batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_DISABLED'),
],
strategy: const SdkStrategy(
enabled: false,
defaultSampleRate: 1.0,
eventSettings: <String, EventStrategy>{},
),
),
);
await core.track('EVENT_DISABLED');
expect(await storage.count(), 0);
await core.dispose();
});
test('事件级策略 disabled 时丢弃事件', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final config = _testConfig(enableDebug: true, batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_OFF'),
],
strategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1.0,
eventSettings: <String, EventStrategy>{
'EVENT_OFF': EventStrategy(enabled: false, sampleRate: 0.0),
},
),
),
);
await core.track('EVENT_OFF');
expect(await storage.count(), 0);
await core.dispose();
});
test('采样策略根据随机数决定是否入库', () async {
final strategy = const SdkStrategy(
enabled: true,
defaultSampleRate: 1.0,
eventSettings: <String, EventStrategy>{
'EVENT_SAMPLE': EventStrategy(enabled: true, sampleRate: 0.5),
},
);
final storageDropped = MemoryEventStorage();
late TestConfigManager configManagerDropped;
final coreDropped = AnalyticsCore(
storageFactory: () => storageDropped,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManagerDropped = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
randomDouble: () => 0.9,
);
await coreDropped.init(_testConfig(enableDebug: true, batchSize: 10));
configManagerDropped.setCurrentConfig(
_dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_SAMPLE'),
],
strategy: strategy,
),
);
await coreDropped.track('EVENT_SAMPLE');
expect(await storageDropped.count(), 0);
await coreDropped.dispose();
final storageKept = MemoryEventStorage();
late TestConfigManager configManagerKept;
final coreKept = AnalyticsCore(
storageFactory: () => storageKept,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManagerKept = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
randomDouble: () => 0.1,
);
await coreKept.init(_testConfig(enableDebug: true, batchSize: 10));
configManagerKept.setCurrentConfig(
_dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_SAMPLE'),
],
strategy: strategy,
),
);
await coreKept.track('EVENT_SAMPLE');
expect(await storageKept.count(), 1);
await coreKept.dispose();
});
});
group('AnalyticsCore.interceptors', () {
test('拦截器可修改事件并收到 afterSend 回调', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final interceptor = RecordingInterceptor(
onBefore: (event) {
final tags = Map<String, dynamic>.from(
event.customTags ?? const <String, dynamic>{},
);
tags['intercepted'] = true;
return event.copyWith(customTags: tags);
},
);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(_testConfig(enableDebug: true, batchSize: 10));
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_INTERCEPT'),
]),
);
core.addInterceptor(interceptor);
await core.track('EVENT_INTERCEPT');
await core.flush(force: true);
final sentEvent = apiClient.sentBatches.single.single;
expect(sentEvent.customTags?['intercepted'], true);
expect(interceptor.afterCalls, 1);
expect(interceptor.afterResults.single.success, true);
await core.dispose();
});
test('拦截器返回 null 会拦截并删除事件', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final interceptor = RecordingInterceptor(onBefore: (_) => null);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(_testConfig(enableDebug: true, batchSize: 10));
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_DROP_BY_INTERCEPTOR'),
]),
);
core.addInterceptor(interceptor);
await core.track('EVENT_DROP_BY_INTERCEPTOR');
await core.flush(force: true);
expect(await storage.count(), 0);
await core.dispose();
});
test('拦截器异常不会影响后续拦截器与发送', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final throwing = RecordingInterceptor(
onBefore: (_) => throw StateError('boom'),
);
final tagging = RecordingInterceptor(
onBefore: (event) {
final tags = Map<String, dynamic>.from(
event.customTags ?? const <String, dynamic>{},
);
tags['tagged'] = 'yes';
return event.copyWith(customTags: tags);
},
);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(_testConfig(enableDebug: true, batchSize: 10));
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_THROW'),
]),
);
core.addInterceptor(throwing);
core.addInterceptor(tagging);
await core.track('EVENT_THROW');
await core.flush(force: true);
final sentEvent = apiClient.sentBatches.single.single;
expect(sentEvent.customTags?['tagged'], 'yes');
await core.dispose();
});
});
group('AnalyticsCore.metrics', () {
test('reportMetricsNow 会生成指标事件并包含计数', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final config = AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com/api/ExternalEventlogs',
clientType: 3,
enableDebug: true,
batchSize: 10,
flushInterval: 3600,
enableMetrics: true,
metricsReportInterval: const Duration(days: 1),
);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(config);
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_METRIC'),
]),
);
await core.track('EVENT_METRIC');
await core.track('EVENT_METRIC');
await core.flush(force: true);
await core.reportMetricsNow();
final metricsSendEvents = storage.insertedEvents
.where((e) => e.eventType == 'SDK_METRICS_SEND')
.toList(growable: false);
expect(metricsSendEvents, isNotEmpty);
final metricsParams =
metricsSendEvents.last.eventParams ?? const <String, dynamic>{};
expect(metricsParams['sentCount'], 2);
expect(apiClient.sentBatches, isNotEmpty);
await core.dispose();
});
});
group('MemoryEventStorage contract', () {
Event buildEvent(String type, DateTime createTime) {
final ts = createTime.millisecondsSinceEpoch;
return Event(
systemCode: 'TEST_APP',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: ts,
timestamp: createTime.toUtc().toIso8601String(),
deviceInfo: _testDeviceInfo,
eventParams: null,
customTags: null,
createTime: createTime,
);
}
test('trimToMaxSize 会删除最旧事件并返回删除数', () async {
final storage = MemoryEventStorage();
await storage.init();
final now = DateTime.now();
await storage.insert(buildEvent('E1', now.subtract(const Duration(seconds: 3))));
await storage.insert(buildEvent('E2', now.subtract(const Duration(seconds: 2))));
await storage.insert(buildEvent('E3', now.subtract(const Duration(seconds: 1))));
final trimmed = await storage.trimToMaxSize(2);
expect(trimmed, 1);
expect(await storage.count(), 2);
expect(storage.byId(1), isNull);
});
test('fetchRecent 按时间降序返回', () async {
final storage = MemoryEventStorage();
await storage.init();
final now = DateTime.now();
await storage.insert(buildEvent('OLD', now.subtract(const Duration(seconds: 2))));
await storage.insert(buildEvent('MID', now.subtract(const Duration(seconds: 1))));
await storage.insert(buildEvent('NEW', now));
final recent = await storage.fetchRecent(2);
expect(recent.map((e) => e.event.eventType).toList(), <String>['NEW', 'MID']);
});
test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async {
final storage = MemoryEventStorage();
await storage.init();
final now = DateTime.now();
final id = await storage.insert(buildEvent('RETRY', now));
await storage.updateRetryCount(id, 2);
final stored = storage.byId(id);
expect(stored, isNotNull);
expect(stored!.retryCount, 2);
expect(stored.event.retryCount, 2);
});
});
group('Integration & performance (mocked)', () {
test('断网失败后恢复可补发', () async {
final storage = MemoryEventStorage();
late FakeApiClient apiClient;
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_OFFLINE'),
]),
);
apiClient.exceptionToThrow = const ApiException(
message: 'offline',
retryable: true,
);
await core.track('EVENT_OFFLINE');
await core.flush(force: true);
expect(await storage.count(), 1);
apiClient.exceptionToThrow = null;
await core.flush(force: true);
expect(await storage.count(), 0);
await core.dispose();
});
test('高频 track 1 万次不会崩溃且数据可入库', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: () async => _testDeviceInfo,
schedulerFactory: (interval, onTick) =>
NoopScheduler(interval: interval, onTick: onTick),
);
await core.init(
_testConfig(
enableDebug: false,
batchSize: 20000,
maxCacheSize: 20000,
),
);
configManager.setCurrentConfig(
_dimInfo(events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_STRESS'),
]),
);
for (var i = 0; i < 10000; i++) {
await core.track('EVENT_STRESS');
}
expect(await storage.count(), 10000);
await core.dispose();
});
});
}