yx_tracking_flutter/test/analytics_core_test.dart

1378 lines
41 KiB
Dart
Raw Permalink 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/config_storage.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<int> deleteExpired(DateTime cutoff) async {
final before = _store.length;
_store.removeWhere((_, value) => !value.createTime.isAfter(cutoff));
return before - _store.length;
}
@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 BlockingEventStorage extends MemoryEventStorage {
BlockingEventStorage(this._blocker);
final Completer<void> _blocker;
var _blockedOnce = false;
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (!_blockedOnce) {
_blockedOnce = true;
await _blocker.future;
}
return super.fetchBatch(limit);
}
}
class FakeApiClient extends ApiClient {
FakeApiClient(super.config);
ApiException? exceptionToThrow;
Error? errorToThrow;
final List<List<Event>> sentBatches = <List<Event>>[];
@override
Future<void> sendBatch(List<Event> events) async {
sentBatches.add(events);
final error = errorToThrow;
if (error != null) {
throw error;
}
final exception = exceptionToThrow;
if (exception != null) {
throw exception;
}
}
}
class TestConfigManager extends ConfigManager {
TestConfigManager({
required super.config,
SystemDimInfo? initialConfig,
}) : currentConfigForTesting = initialConfig,
super(refreshInterval: Duration.zero);
@override
SystemDimInfo? get currentConfig => currentConfigForTesting;
SystemDimInfo? currentConfigForTesting;
@override
Future<void> init() async {}
@override
Future<void> fetchAndCacheConfig({bool force = false}) async {}
@override
Future<void> forceRefresh() async {}
@override
Future<void> dispose() async {}
}
class InMemoryConfigStorage implements ConfigStorage {
SystemDimInfo? _info;
@override
Future<void> init() async {}
@override
Future<void> saveSystemDimInfo(SystemDimInfo info) async {
_info = info;
}
@override
Future<SystemDimInfo?> loadSystemDimInfo() async => _info;
@override
Future<void> clear() async {
_info = null;
}
@override
Future<void> dispose() async {}
}
class IntervalConfigManager extends ConfigManager {
IntervalConfigManager({
required super.config,
required Duration interval,
}) : fetchCalls = 0,
forceCalls = 0,
initCalls = 0,
super(
refreshInterval: interval,
storage: InMemoryConfigStorage(),
);
int fetchCalls;
int forceCalls;
int initCalls;
@override
Future<void> init() async {
initCalls += 1;
}
@override
Future<void> fetchAndCacheConfig({bool force = false}) async {
fetchCalls += 1;
if (force) {
forceCalls += 1;
}
}
@override
Future<void> forceRefresh() async {
forceCalls += 1;
}
@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',
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<DeviceInfo> _collectTestDeviceInfo() async => _testDeviceInfo;
Scheduler _noopScheduler(
Duration interval,
Future<void> Function() onTick,
) =>
NoopScheduler(interval: interval, onTick: onTick);
Future<void> _waitUntil(
bool Function() predicate, {
Duration step = const Duration(milliseconds: 5),
int maxTries = 40,
}) async {
for (var i = 0; i < maxTries; i++) {
if (predicate()) {
return;
}
await Future<void>.delayed(step);
}
}
String _eventTypeOfStored(StoredEvent stored) => stored.event.eventType;
const _allEventDefinitions = <EventDefinition>[
EventDefinition(eventCode: 'EVENT_A'),
EventDefinition(eventCode: 'EVENT_RETRY'),
EventDefinition(eventCode: 'EVENT_DROP'),
EventDefinition(eventCode: 'EVENT_NO_CONFIG'),
EventDefinition(eventCode: 'EVENT_DISABLED'),
EventDefinition(eventCode: 'EVENT_OFF'),
EventDefinition(eventCode: 'EVENT_SAMPLE'),
EventDefinition(eventCode: 'EVENT_INTERCEPT'),
EventDefinition(eventCode: 'EVENT_DROP_BY_INTERCEPTOR'),
EventDefinition(eventCode: 'EVENT_THROW'),
EventDefinition(eventCode: 'EVENT_METRIC'),
EventDefinition(eventCode: 'EVENT_OFFLINE'),
EventDefinition(eventCode: 'EVENT_STRESS'),
];
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: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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(batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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(batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting = _dimInfo(
events: _allEventDefinitions,
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: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(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(batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting = _dimInfo(
events: _allEventDefinitions,
strategy: const SdkStrategy(
enabled: false,
defaultSampleRate: 1,
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(batchSize: 10);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting = _dimInfo(
events: _allEventDefinitions,
strategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{
'EVENT_OFF': EventStrategy(enabled: false, sampleRate: 1),
},
),
);
await core.track('EVENT_OFF');
expect(await storage.count(), 0);
await core.dispose();
});
test('采样策略根据随机数决定是否入库', () async {
const strategy = SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{
'EVENT_SAMPLE': EventStrategy(enabled: true, sampleRate: 0.5),
},
);
final storageDropped = MemoryEventStorage();
late TestConfigManager configManagerDropped;
final coreDropped = AnalyticsCore(
storageFactory: () => storageDropped,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManagerDropped = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
randomDouble: () => 0.9,
);
await coreDropped.init(_testConfig(batchSize: 10));
configManagerDropped.currentConfigForTesting = _dimInfo(
events: _allEventDefinitions,
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: FakeApiClient.new,
configManagerFactory: (c) =>
configManagerKept = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
randomDouble: () => 0.1,
);
await coreKept.init(_testConfig(batchSize: 10));
configManagerKept.currentConfigForTesting = _dimInfo(
events: _allEventDefinitions,
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: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
core
..addInterceptor(throwing)
..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',
enableDebug: true,
batchSize: 10,
flushInterval: 3600,
metricsReportInterval: Duration(days: 1),
);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(config);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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();
});
test('构造默认依赖且未 init 时 isInitialized 为 false', () {
final core = AnalyticsCore();
expect(core.isInitialized, isFalse);
});
test('构造时会使用默认的 configManager/scheduler 工厂', () {
final core = AnalyticsCore(
storageFactory: MemoryEventStorage.new,
apiClientFactory: FakeApiClient.new,
);
expect(core.isInitialized, isFalse);
});
test('重复 init 会先 dispose 再重新初始化', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(enableDebug: false));
await core.init(_testConfig(enableDebug: false));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track('EVENT_REINIT');
expect(await storage.count(), 1);
await core.dispose();
});
test('未初始化时 track/flush/cached 查询会被安全忽略', () async {
final core = AnalyticsCore(
storageFactory: MemoryEventStorage.new,
apiClientFactory: FakeApiClient.new,
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.track('EVENT_BEFORE_INIT');
await core.flush();
expect(await core.cachedEventCount(), 0);
expect(await core.cachedRecentEvents(limit: 0), isEmpty);
await core.refreshConfig();
await core.dispose();
});
test('eventType 为空时会被忽略', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig());
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track(' ');
expect(await storage.count(), 0);
await core.dispose();
});
test('校验错误且配置阻断时会直接丢弃事件debug', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
final config = AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com',
enableDebug: true,
flushInterval: 3600,
enableMetrics: false,
blockOnValidationError: true,
);
await core.init(config);
configManager.currentConfigForTesting = _dimInfo();
await core.track('EVENT_BLOCKED');
expect(await storage.count(), 0);
await core.dispose();
});
test('超过 maxCacheSize 时会 trim 并计入 dropped', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(
_testConfig(
enableDebug: false,
maxCacheSize: 1,
batchSize: 99,
),
);
configManager.currentConfigForTesting = _dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_TRIM'),
],
);
await core.track('EVENT_TRIM');
await core.track('EVENT_TRIM');
expect(await storage.count(), 1);
await core.dispose();
});
test('达到 batchSize 会触发一次异步 flush', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
late FakeApiClient apiClient;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(
_testConfig(
enableDebug: false,
batchSize: 1,
),
);
configManager.currentConfigForTesting = _dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_AUTO_FLUSH'),
],
);
await core.track('EVENT_AUTO_FLUSH');
await _waitUntil(() => apiClient.sentBatches.isNotEmpty);
await core.flush(force: true);
await core.dispose();
});
test('refreshConfig(force) 会分别调用 forceRefresh 与 fetch', () async {
final storage = MemoryEventStorage();
late IntervalConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) => configManager = IntervalConfigManager(
config: c,
interval: const Duration(milliseconds: 10),
),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(batchSize: 50));
await core.refreshConfig();
await core.refreshConfig(force: false);
expect(configManager.forceCalls, greaterThanOrEqualTo(1));
expect(configManager.fetchCalls, greaterThanOrEqualTo(1));
await core.dispose();
});
test('flush 发生未知异常时会按可重试处理', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
late FakeApiClient apiClient;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track('EVENT_THROW_UNKNOWN');
apiClient.errorToThrow = StateError('boom');
await core.flush(force: true);
expect(await storage.count(), 1);
await core.dispose();
});
test('已有 flush 进行中时会直接跳过', () async {
final blocker = Completer<void>();
final storage = BlockingEventStorage(blocker);
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track('EVENT_CONCURRENT_FLUSH');
final firstFlush = core.flush(force: true);
await Future<void>.delayed(const Duration(milliseconds: 10));
await core.flush();
blocker.complete();
await firstFlush;
await core.dispose();
});
test('重试次数超过上限时会删除事件', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
late FakeApiClient apiClient;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
const maxRetry = 1;
await core.init(
_testConfig(
enableDebug: false,
batchSize: 10,
maxRetryCount: maxRetry,
),
);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
final now = DateTime.now();
final event = Event(
systemCode: 'TEST_APP',
eventType: 'EVENT_MAX_RETRY',
userInfo: null,
clientType: 3,
clientTimestamp: now.millisecondsSinceEpoch,
timestamp: now.toIso8601String(),
deviceInfo: _testDeviceInfo,
eventParams: null,
customTags: null,
createTime: now,
retryCount: maxRetry,
);
await storage.insert(event);
apiClient.exceptionToThrow = const ApiException(
message: 'offline',
retryable: true,
);
await core.flush(force: true);
expect(await storage.count(), 0);
await core.dispose();
});
test('afterSend 拦截器抛错不会影响主流程', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
late FakeApiClient apiClient;
final interceptor = RecordingInterceptor(
onAfter: (_, __) => throw StateError('after boom'),
);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
interceptors: <AnalyticsInterceptor>[interceptor],
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track('EVENT_AFTER_THROW');
await core.flush(force: true);
expect(apiClient.sentBatches, isNotEmpty);
await core.dispose();
});
test('退避时间未到时 flush 会被跳过', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
late FakeApiClient apiClient;
var now = DateTime.fromMillisecondsSinceEpoch(1000);
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: (c) => apiClient = FakeApiClient(c),
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
now: () => now,
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
await core.track('EVENT_BACKOFF');
apiClient.exceptionToThrow = const ApiException(
message: 'offline',
retryable: true,
);
await core.flush(force: true);
final sentBefore = apiClient.sentBatches.length;
await core.flush();
expect(apiClient.sentBatches.length, sentBefore);
now = now.add(const Duration(minutes: 5));
await core.flush(force: true);
await core.dispose();
});
test('release 模式会为类型不匹配追加校验标记', () async {
final storage = MemoryEventStorage();
late TestConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(enableDebug: false, batchSize: 50));
configManager.currentConfigForTesting = _dimInfo(
events: const <EventDefinition>[
EventDefinition(eventCode: 'EVENT_TYPE_MISMATCH'),
],
tags: const <TagDefinition>[
TagDefinition(tagName: 'age', tagType: 'int', isRequired: false),
],
);
await core.track(
'EVENT_TYPE_MISMATCH',
customTags: const <String, Object?>{'age': 'bad'},
);
final stored = storage.singleOrNull;
final tags = stored?.event.customTags ?? const <String, dynamic>{};
expect(tags['_sdk_type_error_fields'], contains('age'));
await core.dispose();
});
test('metrics/config 定时器会在 tick 时触发任务', () async {
final storage = MemoryEventStorage();
late IntervalConfigManager configManager;
final core = AnalyticsCore(
storageFactory: () => storage,
apiClientFactory: FakeApiClient.new,
configManagerFactory: (c) => configManager = IntervalConfigManager(
config: c,
interval: const Duration(milliseconds: 10),
),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
final config = AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com',
flushInterval: 3600,
metricsReportInterval: Duration(milliseconds: 10),
);
await core.init(config);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(configManager.fetchCalls, greaterThanOrEqualTo(1));
expect(storage.insertedEvents, 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('deleteExpired 会删除早于 cutoff 的事件', () async {
final storage = MemoryEventStorage();
await storage.init();
final now = DateTime.now();
await storage.insert(
buildEvent('OLD', now.subtract(const Duration(days: 8))),
);
await storage.insert(buildEvent('KEEP', now));
final removed = await storage.deleteExpired(
now.subtract(const Duration(days: 7)),
);
expect(removed, 1);
expect(await storage.count(), 1);
expect(storage.singleOrNull?.event.eventType, 'KEEP');
});
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(_eventTypeOfStored).toList(growable: false),
<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: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(_testConfig(enableDebug: false, batchSize: 10));
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
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: FakeApiClient.new,
configManagerFactory: (c) =>
configManager = TestConfigManager(config: c),
deviceInfoCollector: _collectTestDeviceInfo,
schedulerFactory: _noopScheduler,
);
await core.init(
_testConfig(
enableDebug: false,
batchSize: 20000,
maxCacheSize: 20000,
),
);
configManager.currentConfigForTesting =
_dimInfo(events: _allEventDefinitions);
for (var i = 0; i < 10000; i++) {
await core.track('EVENT_STRESS');
}
expect(await storage.count(), 10000);
await core.dispose();
});
});
}