feat: add feedback checkins exam dates

This commit is contained in:
Codex
2026-06-29 01:48:38 +08:00
parent 726d090a8e
commit 8f92d0427e
16 changed files with 1232 additions and 33 deletions

View File

@@ -44,6 +44,8 @@ const ids = {
scorelineSchool: '00000000-0000-0000-0000-000000000831',
tenantClass: '00000000-0000-0000-0000-000000000851',
tenantClassOther: '00000000-0000-0000-0000-000000000852',
examDate: '00000000-0000-0000-0000-000000000861',
tenantExamDate: '00000000-0000-0000-0000-000000000862',
};
const paymentFixture = (() => {
@@ -677,6 +679,13 @@ async function testCatalogAndLearning() {
});
assert.ok(blueprintList.items?.some(item => item.id === ids.practiceBlueprintMock), 'catalog should expose mock exam blueprint');
const examDates = await request('/api/catalog/exam-dates', {
query: { regionId: ids.region },
});
const smokeExamDate = examDates.items?.find(item => item.id === ids.examDate);
assert.equal(smokeExamDate?.examName, '烟测统考', 'catalog should expose tenant exam date');
assert.equal(typeof smokeExamDate?.daysLeft, 'number', 'catalog exam date should include countdown days');
const questionsByNode = await request('/api/catalog/questions', {
query: { contentNodeId: ids.contentNodeSchoolTarget, limit: 20 },
});
@@ -931,6 +940,58 @@ async function testProfile() {
assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user');
assert.ok(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats');
assert.ok(Array.isArray(payload.item?.recentPractices), 'profile should include recent practices');
const countdowns = await request('/api/profile/exam-countdowns');
const profileExamDate = countdowns.items?.find(item => item.id === ids.examDate);
assert.equal(profileExamDate?.examName, '烟测统考', 'profile countdown should include tenant exam date');
assert.equal(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft');
assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region');
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points');
assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score');
assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger');
const duplicateCheckIn = await request('/api/profile/check-in', { method: 'POST' });
assert.equal(duplicateCheckIn.item?.checkedIn, false, 'duplicate daily check-in should be idempotent');
assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in');
assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points');
const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } });
assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event');
const feedback = await request('/api/profile/feedbacks', {
method: 'POST',
body: {
questionId: ids.question,
type: 'question_error',
category: 'answer',
title: '集成测试题目纠错',
description: '这道烟测题目的解析需要租户后台核对。',
contact: 'student@example.test',
metadata: { source: 'api-integration-test' },
},
});
assert.equal(feedback.item?.status, 'pending', 'student feedback should start as pending');
assert.equal(feedback.item?.questionId, ids.question, 'student feedback should bind tenant question');
const feedbackList = await request('/api/profile/feedbacks', { query: { status: 'pending', limit: 20 } });
assert.ok(feedbackList.items?.some(item => item.id === feedback.item.id), 'profile feedback list should include submitted feedback');
const trustedLogin = await loginBySms('13800000000');
const crossTenantFeedback = await request('/api/profile/feedbacks', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${trustedLogin.session.token}` },
method: 'POST',
body: {
questionId: ids.question,
description: '不应跨租户提交旧租户题目纠错。',
},
expectStatus: 403,
});
assert.equal(crossTenantFeedback.code, 'AUTH_TENANT_MISMATCH', 'feedback submission must use authenticated tenant context');
}
async function testScoreline() {
@@ -2273,11 +2334,112 @@ async function testTenantAdminOps() {
});
assert.equal(announcement.item?.content, '集成测试公告', 'tenant admin should upsert announcement');
const examDate = await request('/api/tenant-admin/exam-dates', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantExamDate,
regionId: ids.region,
legacyId: 'integration-exam-date',
examName: '集成测试考试',
examDate: '2030-07-01',
examType: 'school',
description: '租户后台维护的考试日期',
metadata: { source: 'integration-test' },
sortOrder: 2,
isActive: true,
},
});
assert.equal(examDate.item?.id, ids.tenantExamDate, 'tenant admin should upsert exam date');
const examDates = await request('/api/tenant-admin/exam-dates', {
userId: TENANT_ADMIN_USER_ID,
query: { regionId: ids.region, limit: 20 },
});
assert.ok(examDates.items?.some(item => item.id === ids.tenantExamDate), 'tenant admin should list tenant exam date');
const studentExamDateDenied = await request('/api/tenant-admin/exam-dates', {
expectStatus: 403,
});
assert.equal(studentExamDateDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant exam date admin');
const publicBanners = await request('/api/catalog/banners', {
query: { regionId: ids.region },
});
assert.ok(publicBanners.items?.some(item => item.title === '集成测试活动'), 'public catalog should expose active tenant banner');
const publicExamDates = await request('/api/catalog/exam-dates', {
query: { regionId: ids.region, limit: 20 },
});
assert.ok(publicExamDates.items?.some(item => item.id === ids.tenantExamDate), 'public catalog should expose tenant admin exam date');
const feedbacksBefore = await request('/api/tenant-admin/feedbacks', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'pending', limit: 20 },
});
const pendingFeedback = feedbacksBefore.items?.find(item => item.title === '集成测试题目纠错');
assert.ok(pendingFeedback?.id, 'tenant admin should list student feedback');
const studentFeedbackDenied = await request('/api/tenant-admin/feedbacks', {
expectStatus: 403,
});
assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin');
const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
reportId: pendingFeedback.id,
status: 'resolved',
priority: 'high',
resolution: '已核对并修正解析。',
note: '集成测试处理完成',
rewardPoints: 5,
metadata: { source: 'integration-test' },
},
});
assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback');
assert.equal(resolvedFeedback.item?.reward?.eventType, 'feedback_reward', 'feedback reward should write score ledger');
const rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } });
const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter(
item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id,
).length || 0;
assert.equal(rewardCountAfterResolve, 1, 'feedback reward should be recorded once after first resolution');
const repeatedReward = await request('/api/tenant-admin/feedbacks/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
reportId: pendingFeedback.id,
status: 'resolved',
priority: 'high',
resolution: '重复处理不应重复发积分。',
rewardPoints: 5,
},
});
assert.equal(repeatedReward.item?.reward, null, 'repeated feedback reward should be idempotent');
const rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } });
const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter(
item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id,
).length || 0;
assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates');
const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', {
userId: TENANT_ADMIN_USER_ID,
query: { reportId: pendingFeedback.id },
});
assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'pending'), 'feedback events should include initial pending event');
assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'resolved'), 'feedback events should include resolved event');
const partnerFeedbackDenied = await request('/api/tenant-admin/feedbacks', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
expectStatus: 403,
});
assert.equal(partnerFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback admin must be tenant isolated');
const batch = await request('/api/tenant-admin/code-batches', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',

View File

@@ -54,6 +54,8 @@ const ids = {
recentPractice: '00000000-0000-0000-0000-000000000841',
tenantClass: '00000000-0000-0000-0000-000000000851',
tenantClassOther: '00000000-0000-0000-0000-000000000852',
examDate: '00000000-0000-0000-0000-000000000861',
examDateSchool: '00000000-0000-0000-0000-000000000862',
partnerTenant: '00000000-0000-0000-0000-000000000901',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
partnerInvoice: '00000000-0000-0000-0000-000000000903',
@@ -69,6 +71,43 @@ async function main() {
try {
await client.query('begin');
await client.query(
`
delete from public.report_status_events
where tenant_id = $1
and report_id in (
select id from public.reports
where tenant_id = $1
and (
user_id in ($2::uuid, $3::uuid)
or question_id in ($4::uuid, $5::uuid)
)
)
`,
[tenantId, ids.user, ids.tenantAdminUser, ids.question, ids.questionTwo],
);
await client.query(
`
delete from public.reports
where tenant_id = $1
and (
user_id in ($2::uuid, $3::uuid)
or question_id in ($4::uuid, $5::uuid)
)
`,
[tenantId, ids.user, ids.tenantAdminUser, ids.question, ids.questionTwo],
);
await client.query(
`
delete from public.user_score_events
where tenant_id = $1
and user_id in ($2::uuid, $3::uuid)
`,
[tenantId, ids.user, ids.tenantAdminUser],
);
await client.query(
`
delete from public.crm_webhook_queue
@@ -252,6 +291,16 @@ async function main() {
[ids.user, ids.authUser],
);
await client.query(
`
update public.platform_users
set score = 0,
updated_at = now()
where id in ($1::uuid, $2::uuid)
`,
[ids.user, ids.tenantAdminUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
@@ -389,6 +438,45 @@ async function main() {
[ids.region, tenantId],
);
await client.query(
`
update public.student_profiles
set region_id = $3,
last_check_in_date = null,
stats = stats - 'checkInStreak' - 'lastCheckInPoints',
updated_at = now()
where tenant_id = $1
and user_id in ($2::uuid, $4::uuid)
`,
[tenantId, ids.user, ids.region, ids.secondStudentUser],
);
await client.query(
`
insert into public.exam_dates (
id, tenant_id, region_id, school_id, legacy_id, exam_name,
exam_date, exam_type, description, metadata, sort_order, is_active
)
values (
$1, $2, $3, null, 'smoke-exam-date', '烟测统考',
'2030-06-15', 'unified', '用于验证学生端考试倒计时和租户后台考试日期维护',
'{"source":"smoke-seed"}'::jsonb, 1, true
)
on conflict (id)
do update set region_id = excluded.region_id,
school_id = excluded.school_id,
exam_name = excluded.exam_name,
exam_date = excluded.exam_date,
exam_type = excluded.exam_type,
description = excluded.description,
metadata = excluded.metadata,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
`,
[ids.examDate, tenantId, ids.region],
);
await client.query(
`
insert into public.subjects (id, tenant_id, region_id, legacy_id, name, type, sort_order, is_active)