fix(learning): validate tenant question grading rules
This commit is contained in:
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
@@ -50,6 +51,7 @@ public sealed class DirectContentService(
|
||||
QuestionWriteCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateQuestionForPublication(command);
|
||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
@@ -94,6 +96,8 @@ public sealed class DirectContentService(
|
||||
throw new ContentManagementException("questionId is required.", "question_id_required");
|
||||
}
|
||||
|
||||
ValidateQuestionForPublication(command);
|
||||
|
||||
var question = await dbContext.Questions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value,
|
||||
cancellationToken);
|
||||
@@ -1496,6 +1500,27 @@ public sealed class DirectContentService(
|
||||
question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
||||
}
|
||||
|
||||
private static void ValidateQuestionForPublication(QuestionWriteCommand command)
|
||||
{
|
||||
var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
||||
if (status != QuestionStatus.Published)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var type = Normalize(command.Type) ?? "choice";
|
||||
if (!QuestionGrader.HasValidAuthoritativeAnswer(
|
||||
type,
|
||||
command.CorrectOptionIndex,
|
||||
command.CorrectOptionIndices,
|
||||
command.AnswerText))
|
||||
{
|
||||
throw new ContentManagementException(
|
||||
"Published questions require a valid authoritative answer.",
|
||||
"question_grading_rule_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestionVersion BuildQuestionVersion(
|
||||
DirectContentActor actor,
|
||||
Guid questionId,
|
||||
|
||||
@@ -64,6 +64,26 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Published_objective_question_requires_authoritative_answer_but_draft_does_not()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var published = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/questions",
|
||||
new DirectQuestionWriteDto { Type = "choice", Content = "缺少答案", Status = "Published" });
|
||||
using var draft = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/questions",
|
||||
new DirectQuestionWriteDto { Type = "choice", Content = "草稿题", Status = "Draft" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, published.StatusCode);
|
||||
Assert.Equal("question_grading_rule_invalid", await ReadProblemCodeAsync(published));
|
||||
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_upsert_vocabulary_handbook_video_and_operations_content()
|
||||
{
|
||||
@@ -128,7 +148,7 @@ public sealed class DirectContentEndpointTests
|
||||
|
||||
var questionResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-content/questions",
|
||||
new DirectQuestionWriteDto { Type = "choice", Content = "题目" });
|
||||
new DirectQuestionWriteDto { Type = "choice", Content = "题目", CorrectOptionIndex = 0 });
|
||||
var questionJson = await ReadJsonAsync(questionResponse);
|
||||
var questionId = questionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
@@ -148,7 +168,7 @@ public sealed class DirectContentEndpointTests
|
||||
{
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题" })
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题", correctOptionIndex = 0 })
|
||||
]
|
||||
});
|
||||
var previewJson = await ReadJsonAsync(previewResponse);
|
||||
@@ -160,7 +180,7 @@ public sealed class DirectContentEndpointTests
|
||||
{
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题" })
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题", correctOptionIndex = 0 })
|
||||
]
|
||||
});
|
||||
var executeJson = await ReadJsonAsync(executeResponse);
|
||||
@@ -198,7 +218,7 @@ public sealed class DirectContentEndpointTests
|
||||
Async = true,
|
||||
Items =
|
||||
[
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题" })
|
||||
JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题", correctOptionIndex = 0 })
|
||||
]
|
||||
});
|
||||
var queuedJob = await queueResponse.Content.ReadFromJsonAsync<BackgroundJobItem>(JsonOptions);
|
||||
@@ -374,4 +394,10 @@ public sealed class DirectContentEndpointTests
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
using var payload = await ReadJsonAsync(response);
|
||||
return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user