feat(feedback): 新增用户反馈提交、分页列表与语音描述
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFeedbackService } from './user-feedback.mjs';
|
||||
|
||||
function createMockPool() {
|
||||
const inserts = [];
|
||||
return {
|
||||
inserts,
|
||||
async query(sql, params) {
|
||||
if (/INSERT INTO h5_user_feedback/.test(sql)) {
|
||||
inserts.push(params);
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('submit feedback stores normalized payload', async () => {
|
||||
const pool = createMockPool();
|
||||
const service = createFeedbackService(pool);
|
||||
const tinyPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const result = await service.submit('user-1', {
|
||||
type: 'bug',
|
||||
title: '页面无法保存',
|
||||
description: '点击保存后没有反应',
|
||||
contact: 'test@example.com',
|
||||
images: [
|
||||
{
|
||||
filename: 'screen.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: tinyPng.toString('base64'),
|
||||
},
|
||||
],
|
||||
context: {
|
||||
pagePath: '/space/page/abc',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(result.id);
|
||||
assert.equal(result.type, 'bug');
|
||||
assert.equal(result.status, 'pending');
|
||||
assert.equal(pool.inserts.length, 1);
|
||||
assert.equal(pool.inserts[0][1], 'user-1');
|
||||
assert.equal(pool.inserts[0][2], 'bug');
|
||||
});
|
||||
|
||||
test('submit feedback rejects invalid type', async () => {
|
||||
const service = createFeedbackService(createMockPool());
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.submit('user-1', {
|
||||
type: 'invalid',
|
||||
title: 'x',
|
||||
description: 'y',
|
||||
}),
|
||||
/反馈类型/,
|
||||
);
|
||||
});
|
||||
|
||||
test('submit feedback rejects empty description', async () => {
|
||||
const service = createFeedbackService(createMockPool());
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.submit('user-1', {
|
||||
type: 'feature',
|
||||
title: '想要导出 PDF',
|
||||
description: ' ',
|
||||
}),
|
||||
/详细描述/,
|
||||
);
|
||||
});
|
||||
|
||||
test('listAll returns paginated board items without contact or images', async () => {
|
||||
let queryCount = 0;
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (/SELECT COUNT\(\*\)/.test(sql)) {
|
||||
return [[{ total: 23 }]];
|
||||
}
|
||||
if (/FROM h5_user_feedback f/.test(sql) && /LIMIT \? OFFSET \?/.test(sql)) {
|
||||
queryCount += 1;
|
||||
assert.equal(params[0], 10);
|
||||
assert.equal(params[1], 10);
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'fb-1',
|
||||
user_id: 'user-2',
|
||||
type: 'bug',
|
||||
title: '按钮无响应',
|
||||
description: '点击保存没有反应',
|
||||
status: 'pending',
|
||||
images_json: JSON.stringify([{ mimeType: 'image/png', dataBase64: 'abc' }]),
|
||||
context_json: JSON.stringify({ pagePath: '/space' }),
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
submitter_display_name: 'Alice',
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
},
|
||||
};
|
||||
|
||||
const service = createFeedbackService(pool);
|
||||
const result = await service.listAll({ page: 2, limit: 10 });
|
||||
|
||||
assert.equal(queryCount, 1);
|
||||
assert.equal(result.page, 2);
|
||||
assert.equal(result.pageSize, 10);
|
||||
assert.equal(result.total, 23);
|
||||
assert.equal(result.totalPages, 3);
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.equal(result.items[0].submitterDisplayName, 'Alice');
|
||||
assert.equal(result.items[0].imageCount, 1);
|
||||
assert.equal(result.items[0].images, undefined);
|
||||
assert.equal(result.items[0].contact, undefined);
|
||||
});
|
||||
|
||||
test('getById hides contact from non-owner', async () => {
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (/WHERE f.id = \?/.test(sql)) {
|
||||
assert.equal(params[0], 'fb-1');
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'fb-1',
|
||||
user_id: 'user-2',
|
||||
type: 'bug',
|
||||
title: '按钮无响应',
|
||||
description: '详细内容',
|
||||
contact: 'secret@example.com',
|
||||
status: 'pending',
|
||||
images_json: '[]',
|
||||
context_json: '{}',
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
submitter_display_name: 'Alice',
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
},
|
||||
};
|
||||
|
||||
const service = createFeedbackService(pool);
|
||||
const item = await service.getById('fb-1', 'viewer-1');
|
||||
assert.equal(item.title, '按钮无响应');
|
||||
assert.equal(item.contact, undefined);
|
||||
|
||||
const ownItem = await service.getById('fb-1', 'user-2');
|
||||
assert.equal(ownItem.contact, 'secret@example.com');
|
||||
});
|
||||
Reference in New Issue
Block a user