HarmonyOS宠物邻里实战第14篇:帖子删除回归测试、评论互动清理与通知回收

摘要

社区帖子删除不是简单删一条 posts 记录。宠物邻里项目里,一篇帖子可能关联评论、点赞、收藏、评论点赞、评论通知、点赞通知、用户发帖数和前端列表缓存。如果只删除帖子本身,后端 MongoDB 会留下孤儿数据,HarmonyOS 前端也可能出现“通知能点开但帖子不存在”“评论列表引用空帖子”“个人主页发帖数不准”等问题。

本文基于宠物邻里 HarmonyOS + Express + MongoDB 项目,复盘社区帖子删除的完整工程闭环:

  • 删除帖子前为什么要校验作者身份;
  • 后端如何清理评论、互动和通知;
  • postCount 如何回收;
  • 评论通知和点赞通知为什么要一起删;
  • 如何用端到端集成测试验证删除结果;
  • HarmonyOS 前端 PostTabPostDetailPageMockStore 如何响应删除;
  • 最后给出帖子删除发布前验收清单。

这篇文章重点是回归测试。删除接口最容易在功能演示时被忽略,但它直接影响数据一致性和用户体验。

工程背景与源码定位

文件 作用
houduan/test/routes/api.js 帖子、评论、互动、通知相关接口
houduan/test/test/integration.js 后端集成测试
houduan/test/db.js MongoDB 连接
MyApp/entry/src/main/ets/pages/post/PostTab.ets 社区帖子列表
MyApp/entry/src/main/ets/pages/post/PostDetailPage.ets 帖子详情、点赞、收藏、评论和删除
MyApp/entry/src/main/ets/common/MockStore.ets 本地帖子快照和版本号
MyApp/entry/src/main/ets/services/BackendService.ets 后端接口语义层
MyApp/entry/src/main/ets/services/ApiClient.ets HTTP 请求封装

验证命令:

cd D:\APP\chong_wu_guan_li\houduan\test
npm run check
npm run test:integration

环境与版本信息

项目
HarmonyOS 工程模型 modelVersion: 6.0.2
target SDK 6.0.2(22)
ArkTS 状态入口 PostTab.etsPostDetailPage.etsMockStore.ets
后端框架 Express ~4.16.1
MongoDB Driver ^4.17.2
Node.js 测试入口 houduan/test/test/integration.js
语法检查命令 npm run check
集成测试命令 npm run test:integration

package.json 中的脚本如下:

{
  "scripts": {
    "check": "node --check app.js && node --check db.js && node --check seed.js && node --check middleware/security.js && node --check routes/api.js && node --check routes/admin.js && node --check test/integration.js",
    "test:integration": "node ./test/integration.js"
  },
  "dependencies": {
    "express": "~4.16.1",
    "mongodb": "^4.17.2",
    "cookie-parser": "~1.4.4",
    "morgan": "~1.9.1"
  }
}

这组版本信息要写清楚。否则别人照着文章复现时,不知道 Express 路由、MongoDB Driver 返回结构和 Node.js fetch 是否一致。

宠物邻里社区帖子删除回归测试预览

一、帖子删除为什么要做回归测试

帖子删除涉及的集合比表面上多:

集合 删除原因
posts 主帖子记录
comments 帖子下的评论
postInteractions 点赞、收藏等帖子互动
commentInteractions 评论点赞等互动
notices 评论通知、点赞通知
profiles 作者 postCount 回收

如果漏掉其中一个集合,就会出现数据残留。例如:

  • comments 没删:评论引用不存在的帖子;
  • postInteractions 没删:点赞收藏统计残留;
  • commentInteractions 没删:评论点赞引用空评论;
  • notices 没删:通知列表还能看到已删除帖子事件;
  • postCount 没减:用户主页发帖数不准确。

所以帖子删除必须端到端测试,不能只看接口返回 200。

二、后端删除接口

帖子删除接口在 routes/api.js 中:

router.delete('/posts/:id', async function(req, res, next) {
  try {
    const db = getDatabase();
    const post = await db.collection('posts').findOne({ id: req.params.id });
    if (!post) {
      return fail(res, 404, '帖子不存在');
    }
    if (post.authorId !== req.auth.profileId) {
      return fail(res, 403, '只能删除自己发布的帖子');
    }
    const comments = await db.collection('comments')
      .find({ postId: req.params.id }).project({ id: 1 }).toArray();
    const commentIds = comments.map((item) => item.id);
    await Promise.all([
      db.collection('posts').deleteOne({ id: req.params.id }),
      db.collection('comments').deleteMany({ postId: req.params.id }),
      db.collection('postInteractions').deleteMany({ postId: req.params.id }),
      db.collection('commentInteractions').deleteMany({ commentId: { $in: commentIds } }),
      db.collection('notices').deleteMany({
        $or: [
          { messageId: { $in: commentIds } },
          { kind: 'like', messageId: req.params.id }
        ]
      }),
      db.collection('profiles').updateOne(
        { id: post.authorId },
        { $inc: { postCount: -1 } }
      )
    ]);
    ok(res, { id: req.params.id }, '帖子已删除');
  } catch (error) {
    next(error);
  }
});

这段代码已经覆盖了帖子删除的主要清理范围。

删除接口行为表

为了方便测试,先把接口行为整理成表:

场景 请求用户 预期状态码 预期结果
帖子不存在 任意登录用户 404 不清理任何集合
非作者删除 第三方用户 403 帖子、评论、通知都保留
作者删除 帖子作者 200 主帖、评论、互动、通知清理
重复删除 作者再次删除 404 不重复扣 postCount

后端测试不能只覆盖成功路径。删除接口如果没有越权测试,风险很高。

三、第一层校验:帖子必须存在

接口首先查询帖子:

const post = await db.collection('posts').findOne({ id: req.params.id });
if (!post) {
  return fail(res, 404, '帖子不存在');
}

这一步很普通,但它决定了后续行为。不存在的帖子不应该继续清理评论或互动,否则可能因为 ID 重复或空条件误删其他数据。

四、第二层校验:只能删除自己的帖子

权限校验:

if (post.authorId !== req.auth.profileId) {
  return fail(res, 403, '只能删除自己发布的帖子');
}

前端可以隐藏“删除”按钮,但后端必须自己判断。测试时要专门用另一个用户 Token 删除帖子,断言返回 403。

五、删除前先收集评论 ID

删除评论互动前,需要先找到评论 ID:

const comments = await db.collection('comments')
  .find({ postId: req.params.id }).project({ id: 1 }).toArray();
const commentIds = comments.map((item) => item.id);

这是因为 commentInteractions 不是按 postId 存,而是按 commentId 存。如果先删评论,再查评论 ID,就找不到需要清理的互动了。

删除类接口常见原则:先收集关联 ID,再执行删除。

六、主记录和评论清理

主记录删除:

db.collection('posts').deleteOne({ id: req.params.id })

评论删除:

db.collection('comments').deleteMany({ postId: req.params.id })

这两项是一对。帖子删除后,评论没有存在意义。前端评论详情页、通知跳转、评论点赞都不应该再引用这些评论。

七、帖子互动清理

帖子互动包括点赞和收藏:

db.collection('postInteractions').deleteMany({ postId: req.params.id })

这一步会清理所有用户对这篇帖子的点赞和收藏状态。否则用户再次打开收藏列表时,可能看到一个无法打开的帖子。

八、评论互动清理

评论互动按评论 ID 清理:

db.collection('commentInteractions').deleteMany({ commentId: { $in: commentIds } })

如果某篇帖子没有评论,commentIds 是空数组,$in: [] 不会匹配任何记录,这是安全的。

九、通知回收

通知清理有两个方向:

db.collection('notices').deleteMany({
  $or: [
    { messageId: { $in: commentIds } },
    { kind: 'like', messageId: req.params.id }
  ]
})

第一类是评论通知。评论通知的 messageId 关联评论 ID,所以要用 commentIds 删除。

第二类是点赞通知。点赞通知的 messageId 是帖子 ID,所以按 { kind: 'like', messageId: req.params.id } 删除。

这一步很重要。否则帖子删了,作者通知页里还会显示“某某点赞了你的帖子”,但点进去已经没有帖子。

十、postCount 回收

删除帖子后要更新作者资料:

db.collection('profiles').updateOne(
  { id: post.authorId },
  { $inc: { postCount: -1 } }
)

更严谨的做法是防止负数:

db.collection('profiles').updateOne(
  { id: post.authorId, postCount: { $gt: 0 } },
  { $inc: { postCount: -1 } }
)

如果历史数据中 postCount 已经异常,直接减一可能变成负数。当前项目可以先保留简单实现,后续做数据修复时再加强。

十一、端到端测试流程

帖子删除回归测试建议覆盖以下步骤:

  1. 启动 Express 测试服务;
  2. 注册作者、评论者、第三方用户;
  3. 作者发布帖子;
  4. 评论者点赞帖子;
  5. 评论者收藏帖子;
  6. 评论者评论帖子;
  7. 第三方点赞评论;
  8. 验证作者收到点赞/评论通知;
  9. 第三方尝试删除帖子,断言 403;
  10. 作者删除帖子,断言 200;
  11. 直连 MongoDB 验证关联集合清理;
  12. 读取 /bootstrap 验证快照中没有该帖子和通知。

这样才能证明删除接口真的清干净。

十二、测试脚本骨架

const assert = require('assert');
const { spawn } = require('child_process');
const { MongoClient } = require('mongodb');

const port = 3112;
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() };
}

等待服务启动:

async function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function waitForServer() {
  for (let i = 0; i < 40; i++) {
    try {
      const response = await api('/health');
      if (response.status === 200) {
        return;
      }
    } catch (_error) {
      // The child process may still be connecting to MongoDB.
    }
    await sleep(250);
  }
  throw new Error('Integration server did not become ready');
}

启动服务时设置独立端口和较高限流:

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 createdAccounts = [];

async function cleanupAccounts() {
  for (const account of createdAccounts) {
    try {
      await api('/auth/delete-account', 'POST', account.token, {
        password,
        confirmation: '注销账号'
      });
    } catch (_error) {
      // 保留主断言为第一错误来源。
    }
  }
}

十三、创建帖子和互动

作者发布帖子:

const author = await register('post_author_');
const actor = await register('post_actor_');
const third = await register('post_third_');
const postId = `delete_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));

发布成功后可以直接查 MongoDB,确认帖子作者和计数:

let client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
let db = client.db(process.env.MONGODB_DB || 'chongwu');

let storedPost = await db.collection('posts').findOne({ id: postId });
assert.strictEqual(storedPost.authorId, author.profileId);

let authorProfile = await db.collection('profiles').findOne({ id: author.profileId });
assert.ok(authorProfile.postCount >= 1);
await client.close();

点赞和收藏:

response = await api(`/posts/${postId}/like`, 'PUT', actor.token, { liked: true });
assert.strictEqual(response.status, 200, JSON.stringify(response.json));

response = await api(`/posts/${postId}/favorite`, 'PUT', actor.token, { favorite: true });
assert.strictEqual(response.status, 200, JSON.stringify(response.json));

评论:

const commentId = `delete_comment_${suffix}`;
response = await api(`/posts/${postId}/comments`, 'POST', actor.token, {
  id: commentId,
  content: '这条评论会随着帖子删除一起清理。'
});
assert.strictEqual(response.status, 200, JSON.stringify(response.json));

评论点赞:

response = await api(`/comments/${commentId}/like`, 'PUT', third.token, { liked: true });
assert.strictEqual(response.status, 200, JSON.stringify(response.json));

互动写入后,也做一次数据库断言:

client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
db = client.db(process.env.MONGODB_DB || 'chongwu');

const postInteraction = await db.collection('postInteractions').findOne({
  userId: actor.profileId,
  postId
});
assert.strictEqual(postInteraction.liked, true);
assert.strictEqual(postInteraction.favorite, true);
await client.close();

十四、删除前快照断言

删除前先确认通知已经生成:

let snapshot = await api('/bootstrap', 'GET', author.token);
const likeNotice = snapshot.json.data.notices.find((item) =>
  item.kind === 'like' && item.messageId === postId
);
const commentNotice = snapshot.json.data.notices.find((item) =>
  item.kind === 'comment' && item.fromUserId === actor.profileId
);
assert.ok(likeNotice);
assert.ok(commentNotice);

如果 /bootstrap 暂时没有把帖子和评论全部映射到前端,也至少要通过数据库确认通知存在:

client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
db = client.db(process.env.MONGODB_DB || 'chongwu');

assert.ok(await db.collection('notices').findOne({
  targetUserId: author.profileId,
  kind: 'like',
  messageId: postId
}));

assert.ok(await db.collection('notices').findOne({
  targetUserId: author.profileId,
  kind: 'comment',
  fromUserId: actor.profileId
}));
await client.close();

这一步能证明测试数据准备完整。如果删除前通知都没有生成,删除后的通知清理断言就没有意义。

十五、越权删除断言

第三方不能删除作者帖子:

response = await api(`/posts/${postId}`, 'DELETE', third.token);
assert.strictEqual(response.status, 403);

同时确认帖子仍然存在:

snapshot = await api('/bootstrap', 'GET', author.token);
assert.ok(snapshot.json.data.posts.find((item) => item.id === postId));

如果这里帖子没了,说明权限校验有漏洞。

越权后还要确认数据库没有被误删:

client = new MongoClient(process.env.MONGODB_URL || 'mongodb://127.0.0.1:27017');
await client.connect();
db = client.db(process.env.MONGODB_DB || 'chongwu');
assert.strictEqual(await db.collection('posts').countDocuments({ id: postId }), 1);
assert.strictEqual(await db.collection('comments').countDocuments({ postId }), 1);
await client.close();

十六、作者删除断言

作者删除自己的帖子:

response = await api(`/posts/${postId}`, 'DELETE', author.token);
assert.strictEqual(response.status, 200, JSON.stringify(response.json));
assert.strictEqual(response.json.data.id, postId);

再读取快照:

snapshot = await api('/bootstrap', 'GET', author.token);
assert.strictEqual(snapshot.json.data.posts.find((item) => item.id === postId), undefined);
assert.strictEqual(snapshot.json.data.comments.find((item) => item.postId === postId), undefined);

如果 /bootstrap 当前还没有返回 posts/comments,可以直接使用 MongoDB 校验。

重复删除应返回 404,避免重复扣 postCount

response = await api(`/posts/${postId}`, 'DELETE', author.token);
assert.strictEqual(response.status, 404);

十七、MongoDB 清理断言

直连 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');

assert.strictEqual(await db.collection('posts').countDocuments({ id: postId }), 0);
assert.strictEqual(await db.collection('comments').countDocuments({ postId }), 0);
assert.strictEqual(await db.collection('postInteractions').countDocuments({ postId }), 0);
assert.strictEqual(await db.collection('commentInteractions').countDocuments({ commentId }), 0);

通知也要验证:

assert.strictEqual(await db.collection('notices').countDocuments({
  $or: [
    { messageId: commentId },
    { kind: 'like', messageId: postId }
  ]
}), 0);

最后检查用户发帖数:

const profile = await db.collection('profiles').findOne({ id: author.profileId });
assert.ok(profile.postCount >= 0);

还可以检查评论通知和点赞通知都已经回收:

const residualNotices = await db.collection('notices').find({
  $or: [
    { messageId: commentId },
    { kind: 'like', messageId: postId }
  ]
}).toArray();
assert.strictEqual(residualNotices.length, 0);

完整测试结束后关闭连接:

await client.close();

十八、前端状态闭环

HarmonyOS 社区页 PostTab 通过版本号刷新:

@StorageLink('postsVersion') @Watch('onPostsChanged') postsVersion: number = 0;

onPostsChanged(): void {
  this.refresh();
}

删除帖子成功后,前端应该:

  1. 从本地帖子数组移除该帖子;
  2. 提升 postsVersion
  3. 回到社区列表或上一页;
  4. 如果后端删除失败,恢复本地数据并提示。

可以设计成:

static deletePost(id: string): boolean {
  const idx = MockStore.posts.findIndex((item: Post) => item.id === id);
  if (idx < 0) return false;
  const removed = MockStore.posts[idx];
  MockStore.posts = MockStore.posts.filter((item: Post) => item.id !== id);
  MockStore.bumpPostsVersion();
  BackendService.deletePost(id).catch((e: Error) => {
    MockStore.posts.splice(Math.min(idx, MockStore.posts.length), 0, removed);
    MockStore.bumpPostsVersion();
    MockStore.reportSyncFailure('帖子删除失败,已恢复本地数据', e);
  });
  return true;
}

这和宠物档案删除的乐观更新模式一致。

十九、通知回收和前端体验

帖子删除后,通知页也要刷新。如果本地 MockStore 中仍有点赞或评论通知,用户点进去会发现帖子不存在。

前端可以在删除帖子后同步清理本地通知:

MockStore.notices = MockStore.notices.filter((notice: Notice) => {
  if (notice.kind === NoticeKind.Like && notice.messageId === postId) {
    return false;
  }
  return !commentIds.includes(notice.messageId);
});
MockStore.bumpNoticeVersion();

如果通知完全来自后端快照,也可以删除后调用:

await MockStore.refreshFromBackend();

两种方式都可以,关键是不要让用户看到已经失效的通知。

二十、常见问题排查

现象 可能原因 排查方式
删除后帖子还在列表 前端未 bump postsVersion 检查本地删除逻辑
删除后评论残留 后端未删 comments comments.postId
删除后点赞收藏残留 未删 postInteractions postInteractions.postId
评论点赞残留 删除前没收集 commentIds commentInteractions.commentId
通知还能点到已删除帖子 未删相关 notices notices.messageId
第三方能删除帖子 后端缺少 authorId 校验 用 third token 测试
postCount 变负数 历史计数异常或重复删除 { postCount: { $gt: 0 } }
删除失败后前端数据丢失 乐观更新没有回滚 检查 catch 分支

二十一、验收清单

  • 不存在的帖子返回 404;
  • 非作者删除返回 403;
  • 作者删除返回 200;
  • posts 中目标帖子被删除;
  • comments 中目标帖子的评论被删除;
  • postInteractions 中目标帖子的互动被删除;
  • commentInteractions 中目标评论的互动被删除;
  • 点赞通知和评论通知被删除;
  • 作者 postCount 回收;
  • 前端列表移除帖子;
  • 删除失败时前端能回滚;
  • 通知页不会显示已删除帖子的通知;
  • 集成测试直接校验 MongoDB 残留。

总结

帖子删除是社区模块的数据一致性考题。宠物邻里项目的删除接口不仅删除 posts,还同步清理评论、帖子互动、评论互动、通知,并回收作者发帖数。前端则需要把删除动作转换成本地列表更新和版本号刷新,避免用户继续看到已删除内容。

这类接口最适合用端到端回归测试守住质量。测试从发布帖子、点赞收藏、评论、评论点赞、通知生成开始,再验证越权删除、作者删除和 MongoDB 残留。只有这些断言都通过,社区删除链路才算真正闭环。

Logo

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

更多推荐