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 commentlikesystem
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()
  });
}

后续建议在评论通知里补充 postIdcommentId,这样 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()
});

requestIdmessageId 对前端很重要。通知详情页后续可以根据这两个字段打开寄养详情页,并定位到留言区。

七、全部已读接口必须按用户过滤

全部已读接口:

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。测试流程如下:

  1. 自动启动 Express 服务;
  2. 注册 authoractorthird 三个用户;
  3. author 发布帖子;
  4. actor 点赞帖子;
  5. 验证 author 收到 like 通知;
  6. actor 评论帖子;
  7. 验证 author 收到 comment 通知;
  8. actor 关注 author
  9. 验证 author 收到 system 通知;
  10. 调用单条已读接口;
  11. 调用全部已读接口;
  12. 使用 third 尝试删除 author 的通知,断言 404;
  13. 使用 author 删除通知,断言成功;
  14. 直连 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 通知;
  • 寄养留言和回复都生成系统通知;
  • 通知包含 targetUserIdfromUserId
  • 单条已读只能修改自己的通知;
  • 全部已读只修改当前用户通知;
  • 删除接口不能删除别人的通知;
  • 删除通知不删除源帖子、评论或寄养留言;
  • /bootstrap 能返回当前用户通知;
  • 前端能按 kind 显示不同图标和色彩;
  • 未读红点跟随 unread 字段变化;
  • 集成测试结束后清理测试账号和业务数据。

总结

通知系统的质量,取决于它能不能从事件源走到用户界面。宠物邻里项目里,通知由点赞、评论、关注、寄养留言和回复等事件触发,后端写入 notices 集合,并通过 targetUserId 控制已读和删除权限;HarmonyOS 前端通过 NoticeTabNoticeListItemNoticeDetailPage 展示通知、未读红点和删除确认。

这条链路最怕只测其中一段。只测前端列表,看不到后端是否真的生成通知;只测后端接口,看不到 ArkTS 未读状态是否刷新。把事件触发、MongoDB 写入、快照读取、已读接口、删除接口和前端展示串成端到端回归测试,通知系统才算真正稳定。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐