HarmonyOS宠物邻里实战第13篇:通知系统端到端回归测试、已读接口与MongoDB校验
HarmonyOS宠物邻里实战第13篇:通知系统端到端回归测试、已读接口与MongoDB校验
摘要
通知系统是宠物邻里 App 中最容易被低估的模块。它不是一个简单列表,而是社区、寄养、关注、评论、点赞和系统事件的交汇点。用户点赞帖子,帖子作者要收到通知;用户评论帖子,作者要能进入详情查看;用户关注别人,被关注者要收到系统消息;寄养需求有人留言或收到回复,也要通过通知告诉相关用户。
如果通知系统没有端到端测试,问题通常不会马上暴露。前端可能看到一个“通知列表”,但后端没有生成消息;后端生成了消息,但 targetUserId 写错;已读接口能跑,但没有按当前用户过滤;删除接口能删,但可能删到别人的通知。
本文基于宠物邻里 HarmonyOS + Express + MongoDB 项目,复盘通知系统的完整测试闭环:
- 哪些业务动作应该生成通知;
notices集合应该保存哪些字段;- 点赞、评论、关注、寄养留言和回复如何写入通知;
read-all、单条已读、删除接口如何做权限控制;- 如何用集成测试验证通知生成、已读、删除和越权;
- HarmonyOS 端通知列表如何按
kind分类展示; - 当前前端本地状态和后端通知接口如何衔接;
- 最后给出一份通知系统发布前验收清单。
这篇文章重点是可复现测试。通知系统只有靠端到端回归测试,才能证明它不是“看起来有消息”,而是真的从事件源到前端状态都能闭环。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
houduan/test/routes/api.js |
点赞、评论、关注、寄养留言、通知已读和删除接口 |
houduan/test/test/integration.js |
后端端到端集成测试 |
houduan/test/db.js |
MongoDB 连接 |
MyApp/entry/src/main/ets/pages/notice/NoticeTab.ets |
通知列表、筛选和空态 |
MyApp/entry/src/main/ets/pages/notice/NoticeDetailPage.ets |
通知详情、删除确认和已读入口 |
MyApp/entry/src/main/ets/components/notice/NoticeListItem.ets |
通知卡片、未读红点和删除按钮 |
MyApp/entry/src/main/ets/services/BackendService.ets |
后端接口语义层 |
MyApp/entry/src/main/ets/services/ApiClient.ets |
Bearer Token 请求封装 |
验证命令:
cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration
当前后端依赖:
| 依赖 | 版本 |
|---|---|
| Express | ~4.16.1 |
| MongoDB Driver | ^4.17.2 |
| cookie-parser | ~1.4.4 |
| morgan | ~1.9.1 |

一、通知系统的事件地图
通知不应该由通知页自己生成。通知页只是展示结果,真正的事件来自业务动作。
| 事件源 | 操作 | 通知接收者 | 通知类型 |
|---|---|---|---|
| 社区帖子 | 点赞别人帖子 | 帖子作者 | like |
| 社区帖子 | 评论别人帖子 | 帖子作者 | comment |
| 用户关系 | 关注别人 | 被关注者 | system |
| 寄养需求 | 给需求留言 | 需求发布者 | system |
| 寄养留言 | 发布者回复留言 | 留言者 | system |
| 寄养申请 | 申请、接受、状态流转 | 相关用户 | system |
端到端测试要覆盖这些事件源,而不是只测通知接口本身。
二、通知集合字段设计
后端写入 notices 集合时,核心字段应该稳定:
{
id: `n_${Date.now()}`,
targetUserId: 'u_author',
kind: 'like',
fromUserId: 'u_actor',
title: '邻里宠友',
text: '点赞了你的帖子“遛狗路线分享”',
time: '刚刚',
unread: true,
messageId: 'post_001',
requestId: '',
createdAt: new Date()
}
字段含义如下:
| 字段 | 说明 |
|---|---|
id |
通知主键 |
targetUserId |
通知接收者,也是权限过滤核心 |
kind |
comment、like、system |
fromUserId |
触发通知的人 |
title |
通知标题 |
text |
通知正文 |
unread |
是否未读 |
messageId |
关联帖子、评论或留言 |
requestId |
关联寄养需求,可选 |
createdAt |
数据库排序和回归测试断言使用 |
targetUserId 是最关键的字段。已读、删除、快照查询都必须按它过滤。
三、点赞通知:只在状态变化时创建
后端点赞接口的关键逻辑如下:
async function updatePostLike(req, res, next) {
const db = getDatabase();
const liked = req.body.liked === true;
const userId = req.auth.profileId;
const post = await db.collection('posts').findOne({ id: req.params.id });
const interactions = db.collection('postInteractions');
const existing = await interactions.findOne({ userId, postId: req.params.id });
const previous = existing ? Boolean(existing.liked) : false;
await interactions.updateOne(
{ userId, postId: req.params.id },
{
$set: { liked, updatedAt: new Date() },
$setOnInsert: {
id: `pi_${userId}_${req.params.id}`,
userId,
postId: req.params.id,
favorite: false,
createdAt: new Date()
}
},
{ upsert: true }
);
}
通知只在“之前没点赞,现在点赞”时创建:
if (liked && !previous && result.value.authorId !== userId) {
const profile = await db.collection('profiles').findOne({ id: userId });
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: result.value.authorId,
kind: 'like',
fromUserId: userId,
title: profile ? profile.name : '邻里宠友',
text: `点赞了你的帖子“${result.value.title}”`,
time: '刚刚',
unread: true,
messageId: req.params.id,
createdAt: new Date()
});
}
这个判断避免两类问题:
- 重复点击点赞不会重复通知;
- 作者自己点赞自己的帖子不会通知自己。
四、评论通知:只通知帖子作者
评论接口创建评论后,如果评论者不是帖子作者,则生成通知:
if (comment.authorId !== post.authorId) {
const profile = await db.collection('profiles').findOne({ id: comment.authorId });
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: post.authorId,
kind: 'comment',
fromUserId: comment.authorId,
title: profile ? profile.name : '邻里宠友',
text: `评论了你的帖子“${post.title}”`,
time: '刚刚',
unread: true,
createdAt: new Date()
});
}
后续建议在评论通知里补充 postId 和 commentId,这样 HarmonyOS 端可以从通知详情直接跳转到评论上下文。
五、关注通知:关系、计数、通知一起提交
关注接口会同时写关注关系、更新计数、写通知:
if (following && !existing) {
await follows.insertOne({
id: `follow_${followerId}_${followingId}`,
followerId,
followingId,
createdAt: new Date()
});
await db.collection('profiles').updateOne({ id: followerId }, { $inc: { followCount: 1 } });
await db.collection('profiles').updateOne({ id: followingId }, { $inc: { fanCount: 1 } });
const follower = await db.collection('profiles').findOne({ id: followerId });
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: followingId,
kind: 'system',
fromUserId: followerId,
title: follower ? follower.name : '邻里宠友',
text: '关注了你',
time: '刚刚',
unread: true,
createdAt: new Date()
});
}
这类接口要注意一致性。如果关注关系写入成功但通知失败,用户不会收到提醒;如果通知成功但关注关系失败,通知内容又不可信。正式项目可以考虑事务或补偿队列,当前项目阶段用集成测试先把风险覆盖住。
六、寄养留言通知:要带 requestId 和 messageId
寄养需求留言成功后,通知需求发布者:
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: request.authorId,
kind: 'system',
fromUserId: req.auth.profileId,
title: '寄养需求有新留言',
text: `${author ? author.name : '邻里宠友'}留言咨询了“${request.title}”`,
time: '刚刚',
unread: true,
requestId: request.id,
messageId: message.id,
createdAt: new Date()
});
留言回复成功后,通知留言者:
await db.collection('notices').insertOne({
id: `n_${Date.now()}`,
targetUserId: message.authorId,
kind: 'system',
fromUserId: req.auth.profileId,
title: '寄养留言收到回复',
text: `需求发布者回复了你在“${request.title}”下的留言`,
time: '刚刚',
unread: true,
requestId: request.id,
messageId: message.id,
createdAt: new Date()
});
requestId 和 messageId 对前端很重要。通知详情页后续可以根据这两个字段打开寄养详情页,并定位到留言区。
七、全部已读接口必须按用户过滤
全部已读接口:
router.put('/notices/read-all', async function(req, res, next) {
try {
const targetUserId = req.auth.profileId;
const filter = { unread: true, targetUserId };
const result = await getDatabase().collection('notices').updateMany(
filter,
{ $set: { unread: false, readAt: new Date() } }
);
ok(res, { modifiedCount: result.modifiedCount }, '通知已全部标为已读');
} catch (error) {
next(error);
}
});
这里的 targetUserId 不能少。否则一个用户点击“全部已读”,可能把全站所有未读通知都改掉。
八、单条已读接口要防越权
单条已读接口:
router.put('/notices/:id/read', async function(req, res, next) {
try {
const targetUserId = req.auth.profileId;
const filter = { id: req.params.id, targetUserId };
const result = await getDatabase().collection('notices').findOneAndUpdate(
filter,
{ $set: { unread: false, readAt: new Date() } },
{ returnDocument: 'after' }
);
if (!result.value) {
return fail(res, 404, '通知不存在');
}
ok(res, withoutMongoId(result.value));
} catch (error) {
next(error);
}
});
如果其他用户拿同一个通知 ID 请求,只会得到 404。这是有意设计:不要告诉攻击者这条通知是否存在,只告诉他“你看不到”。
九、删除通知也要按用户过滤
删除通知接口:
router.delete('/notices/:id', async function(req, res, next) {
try {
const targetUserId = req.auth.profileId;
const result = await getDatabase().collection('notices').deleteOne({
id: req.params.id,
targetUserId
});
if (result.deletedCount === 0) {
return fail(res, 404, '通知不存在');
}
ok(res, { id: req.params.id }, '通知已删除');
} catch (error) {
next(error);
}
});
删除通知只删除当前用户的通知副本,不应该影响帖子、评论、寄养留言这些源数据。
十、端到端测试方案
通知端到端测试建议单独写一个 test/notice-integration.js,也可以合并进现有 integration.js。测试流程如下:
- 自动启动 Express 服务;
- 注册
author、actor、third三个用户; author发布帖子;actor点赞帖子;- 验证
author收到like通知; actor评论帖子;- 验证
author收到comment通知; actor关注author;- 验证
author收到system通知; - 调用单条已读接口;
- 调用全部已读接口;
- 使用
third尝试删除author的通知,断言 404; - 使用
author删除通知,断言成功; - 直连 MongoDB 验证通知数量和状态。
十一、测试脚本骨架
const assert = require('assert');
const { spawn } = require('child_process');
const { MongoClient } = require('mongodb');
const port = 3111;
const baseUrl = `http://127.0.0.1:${port}/api`;
const password = 'TestPass123';
const suffix = String(Date.now()).slice(-8);
async function api(path, method = 'GET', token = '', body) {
const response = await fetch(baseUrl + path, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
return { status: response.status, json: await response.json() };
}
启动服务时提高限流阈值,避免测试误触限流:
const server = spawn(process.execPath, ['./bin/www'], {
cwd: __dirname + '/..',
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
API_RATE_LIMIT: '1000',
LOGIN_RATE_LIMIT: '100',
REGISTER_RATE_LIMIT: '100'
},
stdio: 'ignore',
windowsHide: true
});
十二、注册用户和创建帖子
async function register(prefix) {
const username = prefix + suffix;
const response = await api('/auth/register', 'POST', '', { username, password });
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
return {
username,
profileId: response.json.data.profileId,
token: response.json.data.token
};
}
const author = await register('notice_author_');
const actor = await register('notice_actor_');
const third = await register('notice_third_');
const postId = `notice_post_${suffix}`;
let response = await api('/posts', 'POST', author.token, {
id: postId,
title: '通知系统端到端测试帖子',
content: '用于验证点赞、评论和通知生成。',
tags: ['测试', '通知'],
location: '上海市 徐汇区'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
帖子创建成功后,后续点赞和评论才有事件源。
十三、点赞通知断言
response = await api(`/posts/${postId}/like`, 'PUT', actor.token, { liked: true });
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
let snapshot = await api('/bootstrap', 'GET', author.token);
const likeNotice = snapshot.json.data.notices.find((item) =>
item.kind === 'like' &&
item.targetUserId === author.profileId &&
item.fromUserId === actor.profileId &&
item.messageId === postId
);
assert.ok(likeNotice, 'like notice should exist');
assert.strictEqual(likeNotice.unread, true);
还要验证重复点赞不会重复通知:
await api(`/posts/${postId}/like`, 'PUT', actor.token, { liked: true });
snapshot = await api('/bootstrap', 'GET', author.token);
const likeNotices = snapshot.json.data.notices.filter((item) =>
item.kind === 'like' && item.messageId === postId && item.fromUserId === actor.profileId
);
assert.strictEqual(likeNotices.length, 1);
十四、评论通知断言
const commentId = `notice_comment_${suffix}`;
response = await api(`/posts/${postId}/comments`, 'POST', actor.token, {
id: commentId,
content: '这是一条用于通知系统测试的评论。'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
snapshot = await api('/bootstrap', 'GET', author.token);
const commentNotice = snapshot.json.data.notices.find((item) =>
item.kind === 'comment' &&
item.targetUserId === author.profileId &&
item.fromUserId === actor.profileId
);
assert.ok(commentNotice, 'comment notice should exist');
assert.strictEqual(commentNotice.unread, true);
这条测试证明评论事件能进入通知列表。
十五、关注通知断言
response = await api(`/profiles/${author.profileId}/follow`, 'PUT', actor.token, {
following: true
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
snapshot = await api('/bootstrap', 'GET', author.token);
const followNotice = snapshot.json.data.notices.find((item) =>
item.kind === 'system' &&
item.targetUserId === author.profileId &&
item.fromUserId === actor.profileId &&
item.text.includes('关注')
);
assert.ok(followNotice, 'follow notice should exist');
关注通知要和粉丝数一起验证:
const authorProfile = snapshot.json.data.profiles.find((item) => item.id === author.profileId);
assert.ok(authorProfile.fanCount >= 1);
如果 profile 快照暂时没返回 fanCount,可以直接查 MongoDB。
十六、已读接口断言
单条已读:
response = await api(`/notices/${likeNotice.id}/read`, 'PUT', author.token);
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.strictEqual(response.json.data.unread, false);
其他用户不能标记这条通知:
response = await api(`/notices/${commentNotice.id}/read`, 'PUT', third.token);
assert.strictEqual(response.status, 404);
全部已读:
response = await api('/notices/read-all', 'PUT', author.token);
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.ok(response.json.data.modifiedCount >= 1);
再次读取快照:
snapshot = await api('/bootstrap', 'GET', author.token);
const stillUnread = snapshot.json.data.notices.filter((item) => item.unread === true);
assert.strictEqual(stillUnread.length, 0);
十七、删除接口断言
越权删除应该失败:
response = await api(`/notices/${commentNotice.id}`, 'DELETE', third.token);
assert.strictEqual(response.status, 404);
本人删除应该成功:
response = await api(`/notices/${commentNotice.id}`, 'DELETE', author.token);
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
snapshot = await api('/bootstrap', 'GET', author.token);
const deleted = snapshot.json.data.notices.find((item) => item.id === commentNotice.id);
assert.strictEqual(deleted, undefined);
这里验证的是“用户自己的通知视图消失”,不应该删除评论本身。
十八、MongoDB 直接校验
为了避免 /bootstrap 映射层掩盖问题,测试可以直连 MongoDB:
const client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
const db = client.db(process.env.MONGODB_DB || 'chongwu');
const authorNotices = await db.collection('notices')
.find({ targetUserId: author.profileId })
.toArray();
assert.ok(authorNotices.some((item) => item.kind === 'like'));
assert.ok(authorNotices.some((item) => item.kind === 'system'));
assert.strictEqual(authorNotices.some((item) => item.targetUserId === third.profileId), false);
await client.close();
数据库校验可以发现两类问题:
- 通知没有写入;
- 通知写入了错误的目标用户。
十九、HarmonyOS 列表展示
前端通知列表使用 NoticeListItem:
private icon(): string {
if (this.notice.kind === NoticeKind.Comment) return '评';
if (this.notice.kind === NoticeKind.Like) return '赞';
return '通';
}
private tone(): ResourceColor {
if (this.notice.kind === NoticeKind.Comment) return $r('app.color.accent_blue');
if (this.notice.kind === NoticeKind.Like) return $r('app.color.brand_primary');
return $r('app.color.accent_green');
}
未读状态通过红点展示:
if (this.notice.unread) {
Circle()
.width(8)
.height(8)
.fill($r('app.color.brand_primary'))
.margin({ left: Spacing.sm })
}
这个 UI 逻辑和后端 unread 字段直接对应。
二十、通知详情和本地已读
通知详情页进入时会读取通知并标记已读:
private refresh(): void {
for (let i = 0; i < MockStore.notices.length; i++) {
if (MockStore.notices[i].id === this.entityId) {
this.notice = MockStore.notices[i];
MockStore.markNoticeRead(this.entityId);
return;
}
}
}
当前前端如果还没有完全接入后端通知已读接口,可以先保持本地已读,后续把 MockStore.markNoticeRead() 扩展为:
static markNoticeRead(id: string): void {
const notice = MockStore.notices.find((item: Notice) => item.id === id);
if (notice === undefined) return;
notice.unread = false;
MockStore.bumpNoticeVersion();
BackendService.markNoticeRead(id).catch((e: Error) => {
notice.unread = true;
MockStore.bumpNoticeVersion();
MockStore.reportSyncFailure('通知已读同步失败,已恢复未读状态', e);
});
}
这个模式和宠物档案的乐观更新一致:先让页面响应,再处理后端失败回滚。
二十一、前后端待补接口
如果要让 HarmonyOS 前端完全接上后端通知接口,BackendService 可以补三个方法:
static async markNoticeRead(id: string): Promise<void> {
BackendService.requireOk(await ApiClient.put('/notices/' + encodeURIComponent(id) + '/read', new EmptyPayload()));
}
static async markAllNoticesRead(): Promise<void> {
BackendService.requireOk(await ApiClient.put('/notices/read-all', new EmptyPayload()));
}
static async deleteNotice(id: string): Promise<void> {
BackendService.requireOk(await ApiClient.delete('/notices/' + encodeURIComponent(id)));
}
注意 encodeURIComponent(id),不要把用户可控 ID 直接拼进 URL。
二十二、失败排查清单
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 点赞后没有通知 | liked && !previous 没触发 |
查 postInteractions |
| 重复点赞产生多条通知 | 没判断 previous | 查同一 messageId 数量 |
| 评论后作者没收到通知 | targetUserId 写错 |
查 notices.targetUserId |
| 全部已读改到别人通知 | 缺少 targetUserId filter |
查 updateMany 条件 |
| 删除别人通知成功 | 删除接口未按用户过滤 | 用第三方 Token 测试 |
| 前端红点不消失 | 本地未 bump version | 查 noticeVersion |
| 通知详情打不开 | 缺少关联业务 ID | 补 postId/requestId/messageId |
| 通知开关关闭仍显示 | 设置页和通知页状态没同步 | 查 settingsVersion |
二十三、发布前验收清单
- 点赞别人帖子生成
like通知; - 重复点赞不重复生成通知;
- 自己点赞自己帖子不生成通知;
- 评论别人帖子生成
comment通知; - 关注别人生成
system通知; - 寄养留言和回复都生成系统通知;
- 通知包含
targetUserId和fromUserId; - 单条已读只能修改自己的通知;
- 全部已读只修改当前用户通知;
- 删除接口不能删除别人的通知;
- 删除通知不删除源帖子、评论或寄养留言;
/bootstrap能返回当前用户通知;- 前端能按
kind显示不同图标和色彩; - 未读红点跟随
unread字段变化; - 集成测试结束后清理测试账号和业务数据。
总结
通知系统的质量,取决于它能不能从事件源走到用户界面。宠物邻里项目里,通知由点赞、评论、关注、寄养留言和回复等事件触发,后端写入 notices 集合,并通过 targetUserId 控制已读和删除权限;HarmonyOS 前端通过 NoticeTab、NoticeListItem 和 NoticeDetailPage 展示通知、未读红点和删除确认。
这条链路最怕只测其中一段。只测前端列表,看不到后端是否真的生成通知;只测后端接口,看不到 ArkTS 未读状态是否刷新。把事件触发、MongoDB 写入、快照读取、已读接口、删除接口和前端展示串成端到端回归测试,通知系统才算真正稳定。
更多推荐



所有评论(0)