feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/notification/models/reminder_alarm.dart';
|
||||
|
||||
void main() {
|
||||
test('ReminderAlarm JSON roundtrip keeps key fields', () {
|
||||
final alarm = ReminderAlarm(
|
||||
eventId: 'evt_1',
|
||||
title: 'Standup',
|
||||
startAt: DateTime(2026, 3, 30, 10, 0),
|
||||
endAt: DateTime(2026, 3, 30, 10, 30),
|
||||
timezone: 'Asia/Shanghai',
|
||||
reminderMinutes: 15,
|
||||
fireAt: DateTime(2026, 3, 30, 9, 45),
|
||||
fireTimeBucket: 29112645,
|
||||
version: 2,
|
||||
location: 'Meeting Room A',
|
||||
notes: 'Daily sync',
|
||||
);
|
||||
|
||||
final decoded = ReminderAlarm.fromJson(alarm.toJson());
|
||||
|
||||
expect(decoded.eventId, alarm.eventId);
|
||||
expect(decoded.title, alarm.title);
|
||||
expect(decoded.startAt, alarm.startAt);
|
||||
expect(decoded.endAt, alarm.endAt);
|
||||
expect(decoded.timezone, alarm.timezone);
|
||||
expect(decoded.reminderMinutes, alarm.reminderMinutes);
|
||||
expect(decoded.fireAt, alarm.fireAt);
|
||||
expect(decoded.fireTimeBucket, alarm.fireTimeBucket);
|
||||
expect(decoded.version, alarm.version);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/notification/models/reminder_alarm.dart';
|
||||
import 'package:social_app/core/notification/services/reminder_scheduler_service.dart';
|
||||
|
||||
void main() {
|
||||
test('buildAlarms uses remindAt cadence until endAt', () {
|
||||
final event = ReminderEventSnapshot(
|
||||
eventId: 'evt_2',
|
||||
title: 'Planning',
|
||||
startAt: DateTime(2026, 3, 30, 10, 0),
|
||||
endAt: DateTime(2026, 3, 30, 10, 40),
|
||||
timezone: 'Asia/Shanghai',
|
||||
reminderMinutes: 15,
|
||||
);
|
||||
|
||||
final alarms = ReminderSchedulerService.buildAlarmsForEvent(
|
||||
event,
|
||||
now: DateTime(2026, 3, 30, 9, 0),
|
||||
);
|
||||
|
||||
expect(alarms.length, 6);
|
||||
expect(alarms.first.fireAt, DateTime(2026, 3, 30, 9, 45));
|
||||
expect(alarms.last.fireAt, DateTime(2026, 3, 30, 10, 35));
|
||||
});
|
||||
|
||||
test(
|
||||
'buildAlarms compensates by scheduling near-now when remindAt passed',
|
||||
() {
|
||||
final event = ReminderEventSnapshot(
|
||||
eventId: 'evt_3',
|
||||
title: 'Review',
|
||||
startAt: DateTime(2026, 3, 30, 10, 0),
|
||||
endAt: DateTime(2026, 3, 30, 10, 20),
|
||||
timezone: 'Asia/Shanghai',
|
||||
reminderMinutes: 30,
|
||||
);
|
||||
final now = DateTime(2026, 3, 30, 10, 5, 0);
|
||||
|
||||
final alarms = ReminderSchedulerService.buildAlarmsForEvent(
|
||||
event,
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(alarms, isNotEmpty);
|
||||
expect(alarms.first.fireAt, now.add(const Duration(seconds: 5)));
|
||||
},
|
||||
);
|
||||
|
||||
test('buildAlarms returns empty when event already ended', () {
|
||||
final event = ReminderEventSnapshot(
|
||||
eventId: 'evt_4',
|
||||
title: 'Expired',
|
||||
startAt: DateTime(2026, 3, 30, 10, 0),
|
||||
endAt: DateTime(2026, 3, 30, 10, 10),
|
||||
timezone: 'Asia/Shanghai',
|
||||
reminderMinutes: 5,
|
||||
);
|
||||
|
||||
final alarms = ReminderSchedulerService.buildAlarmsForEvent(
|
||||
event,
|
||||
now: DateTime(2026, 3, 30, 10, 11),
|
||||
);
|
||||
|
||||
expect(alarms, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user