matrix-dart-sdk/test/msc_extensions/timeline_export_test.dart

353 lines
10 KiB
Dart

import 'package:test/test.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/models/timeline_chunk.dart';
// Mock implementations
// MockClient: Simulates Matrix server responses for `getRoomEvents` calls that
// are used by the `TimelineExportExtension.export` method.
// This also allows testing error scenarios by throwing exceptions on demand.
class MockClient extends Client {
List<Event> serverEvents, dbEvents;
final bool throwError;
MockClient(
super.name, {
this.serverEvents = const [],
this.dbEvents = const [],
this.throwError = false,
});
@override
Future<GetRoomEventsResponse> getRoomEvents(
String roomId,
Direction direction, {
String? from,
String? to,
int? limit,
String? filter,
}) async {
if (throwError) {
throw MatrixException.fromJson({'errcode': 'M_FORBIDDEN'});
}
final chunk = serverEvents
.skip(int.parse(from ?? '0'))
.take(limit ?? serverEvents.length)
.toList();
return GetRoomEventsResponse(
chunk: chunk,
start: from ?? '0',
end: chunk.isEmpty
? '0'
: (serverEvents.indexOf(chunk.last) + 1).toString(),
state: [],
);
}
@override
DatabaseApi? get database => MockDatabase(dbEvents);
}
// MockDatabase: Simulates database access for the `TimelineExportExtension.export`
// method.
class MockDatabase implements DatabaseApi {
final List<Event> dbEvents;
MockDatabase(this.dbEvents);
@override
Future<List<Event>> getEventList(
Room room, {
int start = 0,
int? limit,
bool onlySending = false,
}) async {
if (start >= dbEvents.length) return [];
return dbEvents.skip(start).take(limit ?? 50).toList();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
// Test helpers
Event createTestEvent({
required String eventId,
required String type,
required String msgtype,
required DateTime timestamp,
required Room room,
}) {
return Event(
eventId: eventId,
type: type,
content: {
'msgtype': msgtype,
'body': 'Test message $eventId',
},
senderId: '@user:example.com',
originServerTs: timestamp,
room: room,
status: EventStatus.synced,
);
}
List<Event> createMockEvents({
required int count,
required DateTime startTime,
required Room room,
}) {
return List.generate(
count,
(i) => createTestEvent(
eventId: 'event$i',
type: i % 3 == 0 ? EventTypes.Message : EventTypes.Encrypted,
msgtype: i % 3 == 0 ? MessageTypes.Text : MessageTypes.BadEncrypted,
timestamp: startTime.subtract(Duration(hours: i)),
room: room,
),
);
}
void main() {
group('TimelineExportExtension', () {
late DateTime now;
late MockClient client;
late Room room;
late Timeline timeline;
setUp(() {
now = DateTime.now();
client = MockClient('testclient');
room = Room(id: '!testroom:example.com', client: client);
timeline = Timeline(room: room, chunk: TimelineChunk(events: []));
});
group('basic export functionality', () {
late List<Event> mockEvents;
setUp(() {
mockEvents = createMockEvents(count: 20, startTime: now, room: room);
// Set up initial state
timeline.events.addAll(mockEvents.take(5));
client.dbEvents = mockEvents.take(10).toList();
client.serverEvents = mockEvents;
room.prev_batch = '10';
});
test('exports events from all sources in correct order', () async {
final results = <ExportResult>[];
await for (final result in timeline.export()) {
results.add(result);
}
expect(results.whereType<ExportProgress>().length, greaterThan(1));
expect(results.first, isA<ExportProgress>());
expect(results.last, isA<ExportComplete>());
final complete = results.last as ExportComplete;
expect(complete.events.length, mockEvents.length);
expect(
complete.events.map((e) => e.eventId).toSet(),
mockEvents.map((e) => e.eventId).toSet(),
);
// Verify events are in chronological order
for (int i = 1; i < complete.events.length; i++) {
expect(
complete.events[i].originServerTs
.isBefore(complete.events[i - 1].originServerTs) ||
complete.events[i].originServerTs
.isAtSameMomentAs(complete.events[i - 1].originServerTs),
isTrue,
reason: 'Events should be in reverse chronological order',
);
}
});
test('filters events by date range correctly', () async {
final from = now.subtract(const Duration(hours: 8, seconds: 1));
final until = now.subtract(const Duration(hours: 3, seconds: 1));
final results = <ExportResult>[];
await for (final result in timeline.export(from: from, until: until)) {
results.add(result);
}
final complete = results.last as ExportComplete;
expect(
complete.events.every(
(e) =>
e.originServerTs.isAfter(from) &&
e.originServerTs.isBefore(until),
),
isTrue,
);
});
});
group('pagination handling', () {
test('handles server pagination with large event sets', () async {
final manyServerEvents = createMockEvents(
count: 150,
startTime: now.subtract(const Duration(hours: 10)),
room: room,
);
client = MockClient('testclient', serverEvents: manyServerEvents);
room = Room(id: '!testroom:example.com', client: client);
timeline = Timeline(
room: room,
chunk: TimelineChunk(events: manyServerEvents.take(10).toList()),
);
room.prev_batch = '10';
final results = <ExportResult>[];
var serverProgressUpdates = 0;
var lastTotalEvents = 0;
await for (final result in timeline.export(requestHistoryCount: 100)) {
results.add(result);
if (result is ExportProgress &&
result.source == ExportSource.server) {
serverProgressUpdates++;
expect(result.totalEvents, greaterThanOrEqualTo(lastTotalEvents));
lastTotalEvents = result.totalEvents;
}
}
final complete = results.last as ExportComplete;
expect(complete.events.length, 150);
expect(serverProgressUpdates, equals(2));
expect(
complete.events.map((e) => e.eventId).toSet(),
manyServerEvents.map((e) => e.eventId).toSet(),
);
});
});
group('error handling', () {
test('continues export when server returns error', () async {
client = MockClient('testclient', throwError: true);
room = Room(id: '!testroom:example.com', client: client);
final initialEvents =
createMockEvents(count: 5, startTime: now, room: room);
client.dbEvents = initialEvents;
client.serverEvents = initialEvents;
timeline = Timeline(
room: room,
chunk: TimelineChunk(events: initialEvents),
);
room.prev_batch = '5';
final results = <ExportResult>[];
await for (final result in timeline.export()) {
results.add(result);
if (result is ExportProgress) {
switch (result.source) {
case ExportSource.timeline:
expect(
result.totalEvents == 0 || result.totalEvents == 5,
isTrue,
);
break;
case ExportSource.database:
break;
case ExportSource.server:
// Should not see server progress due to error
fail(
'Should not receive server progress updates when server throws error',
);
}
}
}
final complete = results.last as ExportComplete;
expect(complete.events.length, 5);
expect(
complete.events.map((e) => e.eventId).toSet(),
initialEvents.map((e) => e.eventId).toSet(),
);
});
});
group('event type counting', () {
test('correctly counts media and UTD events', () async {
final mixedEvents = [
createTestEvent(
eventId: 'image1',
type: EventTypes.Message,
msgtype: MessageTypes.Image,
timestamp: now,
room: room,
),
createTestEvent(
eventId: 'video1',
type: EventTypes.Message,
msgtype: MessageTypes.Video,
timestamp: now,
room: room,
),
createTestEvent(
eventId: 'text1',
type: EventTypes.Message,
msgtype: MessageTypes.Text,
timestamp: now,
room: room,
),
createTestEvent(
eventId: 'utd1',
type: EventTypes.Encrypted,
msgtype: MessageTypes.BadEncrypted,
timestamp: now,
room: room,
),
];
client = MockClient('testclient', serverEvents: mixedEvents);
room = Room(id: '!testroom:example.com', client: client);
timeline = Timeline(room: room, chunk: TimelineChunk(events: []));
room.prev_batch = '0';
final results = <ExportResult>[];
await for (final result in timeline.export()) {
results.add(result);
}
final complete = results.last as ExportComplete;
expect(complete.mediaEvents, 2);
expect(complete.utdEvents, 1);
expect(complete.events.length, mixedEvents.length);
final mediaEvents = complete.events.where(
(e) =>
e.type == EventTypes.Message &&
(e.messageType == MessageTypes.Image ||
e.messageType == MessageTypes.Video),
);
expect(mediaEvents.length, 2);
final utdEvents = complete.events.where(
(e) =>
e.type == EventTypes.Encrypted &&
e.messageType == MessageTypes.BadEncrypted,
);
expect(utdEvents.length, 1);
final textEvents = complete.events.where(
(e) =>
e.type == EventTypes.Message &&
e.messageType == MessageTypes.Text,
);
expect(textEvents.length, 1);
});
});
});
}