feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:meeyao_qianwen/features/notifications/data/models/notification_item.dart';
|
||||
import 'package:meeyao_qianwen/features/notifications/data/models/notification_list_result.dart';
|
||||
import 'package:meeyao_qianwen/features/notifications/data/models/notification_payload.dart';
|
||||
import 'package:meeyao_qianwen/features/notifications/data/repositories/notification_repository.dart';
|
||||
import 'package:meeyao_qianwen/features/notifications/presentation/bloc/notification_bloc.dart';
|
||||
|
||||
class _FakeNotificationRepository implements NotificationRepository {
|
||||
final List<NotificationItem> items = [];
|
||||
int unreadCount = 0;
|
||||
int markAllReadCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<NotificationListResult> listNotifications({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
return NotificationListResult(
|
||||
items: items,
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getUnreadCount() async => unreadCount;
|
||||
|
||||
@override
|
||||
Future<NotificationItem> markRead({required String notificationId}) async {
|
||||
final idx = items.indexWhere((i) => i.id == notificationId);
|
||||
if (idx == -1) {
|
||||
throw Exception('Not found');
|
||||
}
|
||||
items[idx] = items[idx].copyWith(isRead: true);
|
||||
unreadCount = unreadCount > 0 ? unreadCount - 1 : 0;
|
||||
return items[idx];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> markAllRead() async {
|
||||
markAllReadCallCount++;
|
||||
final count = unreadCount;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
items[i] = items[i].copyWith(isRead: true);
|
||||
}
|
||||
unreadCount = 0;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
NotificationItem makeItem({
|
||||
String id = 'item-1',
|
||||
String notificationId = 'notif-1',
|
||||
bool isRead = false,
|
||||
}) {
|
||||
return NotificationItem(
|
||||
id: id,
|
||||
notificationId: notificationId,
|
||||
type: 'system',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
payload: const NotificationPayloadNone(),
|
||||
isRead: isRead,
|
||||
createdAt: DateTime(2026, 4, 10),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('NotificationBloc', () {
|
||||
late _FakeNotificationRepository fakeRepo;
|
||||
late NotificationBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
fakeRepo = _FakeNotificationRepository();
|
||||
bloc = NotificationBloc(repository: fakeRepo);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
bloc.dispose();
|
||||
});
|
||||
|
||||
test('initial state has zero unreadCount', () {
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
expect(bloc.state.items, isEmpty);
|
||||
});
|
||||
|
||||
test('RefreshUnreadCount updates unreadCount', () async {
|
||||
fakeRepo.unreadCount = 5;
|
||||
await bloc.handleEvent(RefreshUnreadCount());
|
||||
expect(bloc.state.unreadCount, 5);
|
||||
});
|
||||
|
||||
test('MarkNotificationRead marks item as read', () async {
|
||||
fakeRepo.items.add(makeItem(id: 'n1', isRead: false));
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
await bloc.handleEvent(MarkNotificationRead(notificationId: 'n1'));
|
||||
expect(bloc.state.items.first.isRead, true);
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
});
|
||||
|
||||
test('MarkAllNotificationsRead marks all as read', () async {
|
||||
fakeRepo.items.addAll([
|
||||
makeItem(id: 'n1', isRead: false),
|
||||
makeItem(id: 'n2', isRead: false),
|
||||
]);
|
||||
fakeRepo.unreadCount = 2;
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
|
||||
await bloc.handleEvent(MarkAllNotificationsRead());
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
expect(bloc.state.items.every((i) => i.isRead), true);
|
||||
});
|
||||
|
||||
test(
|
||||
'NotificationCreatedEvent adds item and increments unreadCount',
|
||||
() async {
|
||||
final item = makeItem(id: 'new-1', isRead: false);
|
||||
bloc.handleEvent(NotificationCreatedEvent(item: item));
|
||||
expect(bloc.state.items.length, 1);
|
||||
expect(bloc.state.unreadCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'NotificationCreatedEvent for read item does not increment unreadCount',
|
||||
() async {
|
||||
final item = makeItem(id: 'new-1', isRead: true);
|
||||
bloc.handleEvent(NotificationCreatedEvent(item: item));
|
||||
expect(bloc.state.items.length, 1);
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'NotificationReadUpdatedEvent updates existing item to read',
|
||||
() async {
|
||||
fakeRepo.items.add(makeItem(id: 'n1', isRead: false));
|
||||
fakeRepo.unreadCount = 1;
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
|
||||
bloc.handleEvent(
|
||||
NotificationReadUpdatedEvent(notificationId: 'n1', isRead: true),
|
||||
);
|
||||
expect(bloc.state.items.first.isRead, true);
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('NotificationRevokedEvent removes item', () async {
|
||||
fakeRepo.items.add(
|
||||
makeItem(id: 'n1', notificationId: 'notif-1', isRead: false),
|
||||
);
|
||||
fakeRepo.unreadCount = 1;
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
|
||||
bloc.handleEvent(NotificationRevokedEvent(notificationId: 'notif-1'));
|
||||
expect(bloc.state.items, isEmpty);
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user