feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
@@ -0,0 +1,254 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/inbox/inbox_sync_store.dart';
import 'package:social_app/data/network/i_api_client.dart';
import 'package:social_app/features/messages/data/apis/inbox_api.dart'
show InboxApi;
import 'package:social_app/features/messages/data/models/inbox_message.dart';
import 'package:social_app/features/messages/data/repositories/inbox_repository.dart';
class _FakeInboxRepository implements InboxRepository {
List<InboxMessage> unread = <InboxMessage>[];
List<InboxMessage> read = <InboxMessage>[];
@override
Future<List<InboxMessage>> getMessages({
bool? isRead,
bool forceRefresh = false,
}) async {
if (isRead == true) {
return read;
}
if (isRead == false) {
return unread;
}
return <InboxMessage>[...unread, ...read];
}
@override
Future<InboxMessage> markAsRead(String messageId) async {
throw UnimplementedError();
}
}
class _FakeApiClient implements IApiClient {
final StreamController<String> streamController =
StreamController<String>.broadcast();
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) async {
return streamController.stream;
}
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> get<T>(
String path, {
Map<String, String>? queryParameters,
Options? options,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> put<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
}
InboxMessage _message({
required String id,
required bool isRead,
InboxMessageStatus status = InboxMessageStatus.pending,
}) {
return InboxMessage(
id: id,
recipientId: 'u1',
senderId: 'u2',
messageType: InboxMessageType.calendar,
scheduleItemId: 's1',
friendshipId: null,
content: const {'type': 'invite'},
isRead: isRead,
status: status,
createdAt: DateTime.parse('2026-03-30T07:00:00Z'),
);
}
Future<void> _emitEnvelope(
_FakeApiClient api,
Map<String, Object?> envelope, {
String eventType = 'INBOX_MESSAGE_CREATED',
String streamId = '1743313300000-0',
}) async {
api.streamController.add('id: $streamId');
api.streamController.add('event: $eventType');
api.streamController.add('data: ${jsonEncode(envelope)}');
api.streamController.add('');
await Future<void>.delayed(const Duration(milliseconds: 30));
}
void main() {
test('InboxSyncStore increments unread count on created event', () async {
final repo = _FakeInboxRepository();
final apiClient = _FakeApiClient();
final store = InboxSyncStore(
repository: repo,
inboxApi: InboxApi(apiClient),
);
addTearDown(() async {
await store.stop();
await apiClient.streamController.close();
store.dispose();
});
await store.resetForUser('u1');
await Future<void>.delayed(const Duration(milliseconds: 60));
await _emitEnvelope(apiClient, {
'event_id': 'e1',
'occurred_at': '2026-03-30T07:00:00Z',
'user_id': 'u1',
'message_id': 'm1',
'op': 'created',
'version': 1774854000000,
'data': {
'message': {
'id': 'm1',
'recipient_id': 'u1',
'sender_id': 'u2',
'message_type': 'calendar',
'schedule_item_id': 's1',
'friendship_id': null,
'content': {'type': 'invite'},
'is_read': false,
'status': 'pending',
'created_at': '2026-03-30T07:00:00Z',
},
},
});
expect(store.unreadCount, 1);
expect(store.unreadMessages.single.id, 'm1');
});
test(
'InboxSyncStore decrements unread count on read_changed event',
() async {
final repo = _FakeInboxRepository()
..unread = <InboxMessage>[_message(id: 'm1', isRead: false)];
final apiClient = _FakeApiClient();
final store = InboxSyncStore(
repository: repo,
inboxApi: InboxApi(apiClient),
);
addTearDown(() async {
await store.stop();
await apiClient.streamController.close();
store.dispose();
});
await store.resetForUser('u1');
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(store.unreadCount, 1);
await _emitEnvelope(
apiClient,
{
'event_id': 'e2',
'occurred_at': '2026-03-30T07:00:01Z',
'user_id': 'u1',
'message_id': 'm1',
'op': 'read_changed',
'version': 1774854001000,
'data': {'is_read': true},
},
eventType: 'INBOX_MESSAGE_READ_CHANGED',
streamId: '1743313301000-0',
);
expect(store.unreadCount, 0);
expect(store.readMessages.single.id, 'm1');
},
);
test('InboxSyncStore ignores stale version events', () async {
final repo = _FakeInboxRepository()
..unread = <InboxMessage>[_message(id: 'm1', isRead: false)];
final apiClient = _FakeApiClient();
final store = InboxSyncStore(
repository: repo,
inboxApi: InboxApi(apiClient),
);
addTearDown(() async {
await store.stop();
await apiClient.streamController.close();
store.dispose();
});
await store.resetForUser('u1');
await Future<void>.delayed(const Duration(milliseconds: 60));
await _emitEnvelope(
apiClient,
{
'event_id': 'e3',
'occurred_at': '2026-03-30T07:00:00Z',
'user_id': 'u1',
'message_id': 'm1',
'op': 'read_changed',
'version': 1774853900000,
'data': {'is_read': true},
},
eventType: 'INBOX_MESSAGE_READ_CHANGED',
streamId: '1743313200000-0',
);
expect(store.unreadCount, 1);
});
test('InboxSyncStore clears stale state on user switch', () async {
final repo = _FakeInboxRepository()
..unread = <InboxMessage>[_message(id: 'm1', isRead: false)];
final apiClient = _FakeApiClient();
final store = InboxSyncStore(
repository: repo,
inboxApi: InboxApi(apiClient),
);
addTearDown(() async {
await store.stop();
await apiClient.streamController.close();
store.dispose();
});
await store.resetForUser('u1');
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(store.unreadCount, 1);
repo.unread = <InboxMessage>[];
await store.resetForUser('u2');
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(store.unreadCount, 0);
});
}