feat: resolve public bank conflicts

This commit is contained in:
Codex
2026-06-29 13:50:41 +08:00
parent 93ec1a07d2
commit 7fc92986a5
13 changed files with 547 additions and 32 deletions

View File

@@ -4088,6 +4088,126 @@ async function testPublicQuestionBankAdoption() {
const conflictTargetAfter = collectionAfterConflict.items?.find(item => item.id === conflictTarget.id);
assert.equal(conflictTargetAfter?.content, tenantCustomContent, 'public bank sync must not overwrite tenant-customized copied question');
const crossTenantResolveDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'accept_platform',
},
expectStatus: 404,
});
assert.equal(crossTenantResolveDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank conflict resolution must be tenant isolated');
const acceptedConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'accept_platform',
},
});
assert.equal(acceptedConflict.item?.resolution, 'accept_platform', 'tenant admin should accept platform version for a conflict');
assert.equal(acceptedConflict.item?.remainingConflictCount, 0, 'accepting the only conflict should clear conflict count');
assert.equal(acceptedConflict.item?.syncStatus, 'synced', 'accepted conflict should mark adoption synced when no conflicts remain');
const collectionAfterAccept = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const acceptedTarget = collectionAfterAccept.items?.find(item => item.id === conflictTarget.id);
assert.equal(acceptedTarget?.content, '平台公共题库更新1 + 1 = ?', 'accepting platform conflict should update tenant copy to platform content');
const conflictListAfterAccept = await request('/api/tenant-content/public-question-banks/conflicts', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
});
assert.equal(conflictListAfterAccept.item?.conflictCount, 0, 'resolved conflict list should have no remaining conflicts');
const keepLocalContent = `租户再次保留本地公共题副本 ${Date.now()}`;
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: conflictTarget.id,
createVersion: true,
content: keepLocalContent,
options: acceptedTarget.options || [],
correctOptionIndex: acceptedTarget.correctOptionIndex,
correctOptionIndices: acceptedTarget.correctOptionIndices || [],
answerText: acceptedTarget.answerText,
explanation: '租户这一次选择保留本地版本。',
sourceHash: `tenant-keep-local-${Date.now()}`,
},
});
const platformSecondChangedHash = `platform-source-update-keep-local-${Date.now()}`;
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: ids.question,
createVersion: true,
content: '平台公共题库二次更新1 + 2 = ?',
options: [
{ label: 'A', text: '2' },
{ label: 'B', text: '3' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '3',
explanation: '平台公共题库发布了第二个新版本。',
sourceHash: platformSecondChangedHash,
},
});
const secondConflictSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(secondConflictSync.sync?.status, 'conflict', 'second platform update should conflict with tenant-local edit');
const keptLocalConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'keep_local',
},
});
assert.equal(keptLocalConflict.item?.resolution, 'keep_local', 'tenant admin should keep local version for a conflict');
assert.equal(keptLocalConflict.item?.syncStatus, 'synced', 'keeping the only conflict should mark adoption synced');
const afterKeepLocalSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(afterKeepLocalSync.sync?.status, 'synced', 'future sync should not repeat a keep-local conflict for the same platform hash');
assert.ok(
afterKeepLocalSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'skipped' && item.resolution === 'keep_local'),
'future sync should expose keep-local skip decision',
);
const collectionAfterKeepLocal = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const keptLocalTarget = collectionAfterKeepLocal.items?.find(item => item.id === conflictTarget.id);
assert.equal(keptLocalTarget?.content, keepLocalContent, 'keep-local resolution should preserve tenant customized content');
const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', {
userId: TENANT_ADMIN_USER_ID,
query: { onlyNotAdopted: 'true' },