HarmonyOS宠物邻里实战第14篇:帖子删除回归测试、评论互动清理与通知回收
HarmonyOS宠物邻里实战第14篇:帖子删除回归测试、评论互动清理与通知回收
摘要
社区帖子删除不是简单删一条 posts 记录。宠物邻里项目里,一篇帖子可能关联评论、点赞、收藏、评论点赞、评论通知、点赞通知、用户发帖数和前端列表缓存。如果只删除帖子本身,后端 MongoDB 会留下孤儿数据,HarmonyOS 前端也可能出现“通知能点开但帖子不存在”“评论列表引用空帖子”“个人主页发帖数不准”等问题。
本文基于宠物邻里 HarmonyOS + Express + MongoDB 项目,复盘社区帖子删除的完整工程闭环:
- 删除帖子前为什么要校验作者身份;
- 后端如何清理评论、互动和通知;
postCount如何回收;- 评论通知和点赞通知为什么要一起删;
- 如何用端到端集成测试验证删除结果;
- HarmonyOS 前端
PostTab、PostDetailPage和MockStore如何响应删除; - 最后给出帖子删除发布前验收清单。
这篇文章重点是回归测试。删除接口最容易在功能演示时被忽略,但它直接影响数据一致性和用户体验。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
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.ets、PostDetailPage.ets、MockStore.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 已经异常,直接减一可能变成负数。当前项目可以先保留简单实现,后续做数据修复时再加强。
十一、端到端测试流程
帖子删除回归测试建议覆盖以下步骤:
- 启动 Express 测试服务;
- 注册作者、评论者、第三方用户;
- 作者发布帖子;
- 评论者点赞帖子;
- 评论者收藏帖子;
- 评论者评论帖子;
- 第三方点赞评论;
- 验证作者收到点赞/评论通知;
- 第三方尝试删除帖子,断言 403;
- 作者删除帖子,断言 200;
- 直连 MongoDB 验证关联集合清理;
- 读取
/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();
}
删除帖子成功后,前端应该:
- 从本地帖子数组移除该帖子;
- 提升
postsVersion; - 回到社区列表或上一页;
- 如果后端删除失败,恢复本地数据并提示。
可以设计成:
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 残留。只有这些断言都通过,社区删除链路才算真正闭环。
更多推荐

所有评论(0)