Files
tiku-backend.net/Tiku.IntegrationTests/Api/LearningEndpointTests.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

798 lines
34 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class LearningEndpointTests
{
[Fact]
public async Task Learning_endpoints_require_current_tenant_member()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/student/learning/favorites/questions");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Submit_answer_records_wrong_question_when_self_judged_wrong()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
var answerable = await SeedAnswerableQuestionAsync(factory, seed, questionId);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var response = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "wrong-answer-1",
SelectedOptionIndices = [1]
});
using var submitResponse = await client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "wrong-submit-1"
});
using var wrongResponse = await client.GetAsync("/api/student/learning/wrong-questions");
var answer = await ReadJsonAsync(response);
var wrongItems = await ReadItemsAsync(wrongResponse);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(answerable.SessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
Assert.False(answer.RootElement.TryGetProperty("isCorrect", out _));
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
var wrong = Assert.Single(wrongItems);
Assert.Equal(questionId, wrong.GetProperty("locator").GetProperty("questionId").GetGuid());
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
}
[Fact]
public async Task Practice_session_can_be_created_detailed_submitted_and_listed()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var collectionId = Guid.NewGuid();
var firstQuestionId = Guid.NewGuid();
var secondQuestionId = Guid.NewGuid();
var firstVersionId = Guid.NewGuid();
var secondVersionId = Guid.NewGuid();
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = firstQuestionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new QuestionVersion
{
Id = firstVersionId,
TenantId = seed.TenantId,
QuestionId = firstQuestionId,
VersionNo = 1,
Content = "first",
CorrectOptionIndex = 0
});
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = secondQuestionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new QuestionVersion
{
Id = secondVersionId,
TenantId = seed.TenantId,
QuestionId = secondQuestionId,
VersionNo = 1,
Content = "second",
CorrectOptionIndex = 0
});
var firstReferenceId = Guid.NewGuid();
var secondReferenceId = Guid.NewGuid();
await factory.SeedAsync(
new TenantQuestionReference
{
Id = firstReferenceId,
TenantId = seed.TenantId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = firstQuestionId,
Source = QuestionSource.Tenant
},
new TenantQuestionReference
{
Id = secondReferenceId,
TenantId = seed.TenantId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = secondQuestionId,
Source = QuestionSource.Tenant
},
new QuestionCollection
{
Id = collectionId,
TenantId = seed.TenantId,
Name = "基础练习",
Status = ContentStatus.Active
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
CollectionId = collectionId,
QuestionReferenceId = firstReferenceId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = firstQuestionId,
SortOrder = 1
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
CollectionId = collectionId,
QuestionReferenceId = secondReferenceId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = secondQuestionId,
SortOrder = 2
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var createResponse = await client.PostAsJsonAsync(
"/api/student/learning/practice-sessions",
new CreatePracticeSessionDto
{
Mode = "chapter",
CollectionId = collectionId,
QuestionLimit = 2,
TotalScore = 100
});
var created = await ReadJsonAsync(createResponse);
var practiceSessionId = created.RootElement.GetProperty("id").GetGuid();
var detailResponse =
await client.GetAsync(
$"/api/student/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}");
var detail = await ReadJsonAsync(detailResponse);
var detailQuestions = detail.RootElement.GetProperty("questions").EnumerateArray().ToArray();
var firstSessionQuestionId = detailQuestions.Single(item =>
item.GetProperty("questionId").GetGuid() == firstQuestionId).GetProperty("sessionQuestionId").GetGuid();
var secondSessionQuestionId = detailQuestions.Single(item =>
item.GetProperty("questionId").GetGuid() == secondQuestionId).GetProperty("sessionQuestionId").GetGuid();
var firstAnswerResponse = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = firstSessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "practice-first-answer",
SelectedOptionIndices = [0]
});
var secondAnswerResponse = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = secondSessionQuestionId,
ExpectedSessionVersion = 2,
ClientSequence = 2,
IdempotencyKey = "practice-second-answer",
SelectedOptionIndices = [1]
});
var submitResponse = await client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = practiceSessionId,
ExpectedSessionVersion = 3,
IdempotencyKey = "practice-submit"
});
var report = await ReadJsonAsync(submitResponse);
var reportResponse =
await client.GetAsync(
$"/api/student/learning/practice-sessions/report?practiceSessionId={practiceSessionId}");
var reportsResponse = await client.GetAsync("/api/student/learning/practice-reports");
var historyResponse = await client.GetAsync("/api/student/learning/practice-sessions/history?status=finished");
var reports = await ReadItemsAsync(reportsResponse);
var history = await ReadItemsAsync(historyResponse);
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
Assert.Equal(2, created.RootElement.GetProperty("questionCount").GetInt32());
Assert.Equal("Active", created.RootElement.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(2, detail.RootElement.GetProperty("questions").GetArrayLength());
Assert.True(firstAnswerResponse.IsSuccessStatusCode, await firstAnswerResponse.Content.ReadAsStringAsync());
Assert.True(secondAnswerResponse.IsSuccessStatusCode, await secondAnswerResponse.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
Assert.Equal(2, report.RootElement.GetProperty("totalQuestions").GetInt32());
Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32());
Assert.Equal(1, report.RootElement.GetProperty("wrongCount").GetInt32());
Assert.Equal(50m, report.RootElement.GetProperty("score").GetDecimal());
Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode);
Assert.Single(reports);
var historyItem = Assert.Single(history);
Assert.Equal(practiceSessionId, historyItem.GetProperty("id").GetGuid());
Assert.Equal("finished", historyItem.GetProperty("status").GetString());
}
[Fact]
public async Task Favorite_question_can_be_added_listed_and_removed()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
await factory.SeedAsync(new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var addResponse = await client.PostAsJsonAsync(
"/api/student/learning/favorites/questions",
new QuestionActionDto { QuestionId = questionId });
var listResponse = await client.GetAsync("/api/student/learning/favorites/questions");
var itemsAfterAdd = await ReadItemsAsync(listResponse);
var removeResponse = await client.PostAsJsonAsync(
"/api/student/learning/favorites/questions",
new QuestionActionDto { QuestionId = questionId, Favorite = false });
var emptyResponse = await client.GetAsync("/api/student/learning/favorites/questions");
var itemsAfterRemove = await ReadItemsAsync(emptyResponse);
Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
Assert.Equal(
questionId,
Assert.Single(itemsAfterAdd).GetProperty("locator").GetProperty("questionId").GetGuid());
Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode);
Assert.Empty(itemsAfterRemove);
}
[Fact]
public async Task Word_progress_upserts_and_filters_by_status()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var wordId = Guid.NewGuid();
await factory.SeedAsync(new VocabularyWord
{
Id = wordId,
TenantId = seed.TenantId,
Word = "composition",
IsActive = true
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var updateResponse = await client.PostAsJsonAsync(
"/api/student/learning/vocabulary/progress",
new WordProgressDto
{
WordId = wordId,
Status = "learning",
CorrectDelta = 2
});
using var listResponse = await client.GetAsync("/api/student/learning/vocabulary/progress?status=learning");
var progress = await ReadJsonAsync(updateResponse);
var items = await ReadItemsAsync(listResponse);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
Assert.Equal(2, progress.RootElement.GetProperty("correctCount").GetInt32());
var item = Assert.Single(items);
Assert.Equal(wordId, item.GetProperty("wordId").GetGuid());
Assert.Equal("Learning", item.GetProperty("status").GetString());
}
[Fact]
public async Task Favorite_words_can_be_filtered_by_unit()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var unitId = Guid.NewGuid();
var otherUnitId = Guid.NewGuid();
var wordId = Guid.NewGuid();
var otherWordId = Guid.NewGuid();
await factory.SeedAsync(
new VocabularyUnit
{
Id = unitId,
TenantId = seed.TenantId,
Name = "Unit 1",
IsActive = true
},
new VocabularyUnit
{
Id = otherUnitId,
TenantId = seed.TenantId,
Name = "Unit 2",
IsActive = true
},
new VocabularyWord
{
Id = wordId,
TenantId = seed.TenantId,
UnitId = unitId,
Word = "design",
IsActive = true
},
new VocabularyWord
{
Id = otherWordId,
TenantId = seed.TenantId,
UnitId = otherUnitId,
Word = "sketch",
IsActive = true
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
await client.PostAsJsonAsync(
"/api/student/learning/vocabulary/favorites",
new FavoriteWordDto { WordId = wordId, Note = "重点" });
await client.PostAsJsonAsync(
"/api/student/learning/vocabulary/favorites",
new FavoriteWordDto { WordId = otherWordId });
using var response = await client.GetAsync($"/api/student/learning/vocabulary/favorites?unitId={unitId}");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var item = Assert.Single(items);
Assert.Equal(wordId, item.GetProperty("wordId").GetGuid());
Assert.Equal("重点", item.GetProperty("note").GetString());
}
[Fact]
public async Task Learning_stats_plans_leaderboard_and_word_review_are_available()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
var wordId = Guid.NewGuid();
var answerable = await SeedAnswerableQuestionAsync(factory, seed, questionId);
await factory.SeedAsync(
new VocabularyWord
{
Id = wordId,
TenantId = seed.TenantId,
Word = "review",
IsActive = true
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "stats-wrong-answer",
SelectedOptionIndices = [1]
});
await client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "stats-wrong-submit"
});
await client.PostAsJsonAsync(
"/api/student/learning/vocabulary/progress",
new WordProgressDto
{
WordId = wordId,
Status = "learning",
WrongDelta = 1,
NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1)
});
var statsResponse = await client.GetAsync("/api/student/learning/stats");
var trendResponse = await client.GetAsync("/api/student/learning/trend?limit=7");
var leaderboardResponse = await client.GetAsync("/api/student/learning/leaderboard?limit=10");
var wrongPlanResponse = await client.GetAsync("/api/student/learning/wrong-questions/review-plan");
var wordPlanResponse = await client.GetAsync("/api/student/learning/vocabulary/review-plan");
var reviewResponse = await client.PostAsJsonAsync(
"/api/student/learning/vocabulary/review",
new WordReviewDto { WordId = wordId, Result = "correct" });
var wordStatsResponse = await client.GetAsync("/api/student/learning/vocabulary/stats");
var stats = await ReadJsonAsync(statsResponse);
var trend = await ReadItemsAsync(trendResponse);
var leaderboard = await ReadJsonAsync(leaderboardResponse);
var wrongPlan = await ReadJsonAsync(wrongPlanResponse);
var wordPlan = await ReadJsonAsync(wordPlanResponse);
var review = await ReadJsonAsync(reviewResponse);
var wordStats = await ReadJsonAsync(wordStatsResponse);
Assert.Equal(HttpStatusCode.OK, statsResponse.StatusCode);
Assert.Equal(1, stats.RootElement.GetProperty("answerCount").GetInt32());
Assert.Equal(1, stats.RootElement.GetProperty("wrongCount").GetInt32());
Assert.NotEmpty(trend);
Assert.Equal(seed.UserId,
Assert.Single(leaderboard.RootElement.GetProperty("items").EnumerateArray()).GetProperty("userId")
.GetGuid());
Assert.Single(wrongPlan.RootElement.GetProperty("items").EnumerateArray());
Assert.Single(wordPlan.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal(1, review.RootElement.GetProperty("correctCount").GetInt32());
Assert.Equal(1, wordStats.RootElement.GetProperty("total").GetInt32());
}
[Fact]
public async Task Answers_are_idempotent_versioned_and_keep_revision_history()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var firstRequest = new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "revision-answer-1",
SelectedOptionIndices = [1]
};
var first = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
var replay = await client.PostAsJsonAsync("/api/student/learning/answers", firstRequest);
var conflict = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "revision-answer-1",
SelectedOptionIndices = [0]
});
var revision = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 2,
ClientSequence = 2,
IdempotencyKey = "revision-answer-2",
SelectedOptionIndices = [0]
});
var stale = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 3,
IdempotencyKey = "revision-answer-stale",
SelectedOptionIndices = [0]
});
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
Assert.Equal(await first.Content.ReadAsStringAsync(), await replay.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
Assert.Equal(HttpStatusCode.OK, revision.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.Equal("practice_session_version_conflict", await ReadProblemCodeAsync(stale));
using var scope = factory.CreateSystemScope("Verify immutable answer revisions");
var records = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().AnswerRecords
.Where(item => item.PracticeSessionId == answerable.SessionId)
.OrderBy(item => item.Revision)
.ToArrayAsync();
Assert.Equal(2, records.Length);
Assert.False(records[0].IsCurrent);
Assert.True(records[1].IsCurrent);
Assert.Equal(AnswerGradingStatus.Correct, records[1].GradingStatus);
}
[Fact]
public async Task Session_detail_uses_immutable_safe_snapshot()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var before =
await client.GetAsync(
$"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}");
using (var scope = factory.CreateSystemScope("Mutate source question version after session creation"))
{
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await db.QuestionVersions.SingleAsync(item => item.Id == answerable.VersionId);
version.Content = "mutated source content";
version.Explanation = "must remain hidden";
version.CorrectOptionIndex = 1;
await db.SaveChangesAsync();
}
var after = await client.GetAsync(
$"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}");
var beforeQuestion = (await ReadJsonAsync(before)).RootElement.GetProperty("questions")[0];
var afterQuestion = (await ReadJsonAsync(after)).RootElement.GetProperty("questions")[0];
Assert.Equal("answerable question", beforeQuestion.GetProperty("content").GetString());
Assert.Equal("answerable question", afterQuestion.GetProperty("content").GetString());
Assert.False(beforeQuestion.TryGetProperty("explanation", out _));
Assert.False(afterQuestion.TryGetProperty("correctOptionIndex", out _));
}
[Fact]
public async Task Subjective_answer_produces_non_final_pending_review_report()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid(), "short_answer");
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var answer = await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "subjective-answer",
AnswerText = "student response"
});
var submit = await client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "subjective-submit"
});
var answerBody = await ReadJsonAsync(answer);
var report = await ReadJsonAsync(submit);
var result = report.RootElement.GetProperty("questionResults")[0];
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
Assert.Equal("pending_review", answerBody.RootElement.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.OK, submit.StatusCode);
Assert.Equal("PendingReview", report.RootElement.GetProperty("status").GetString());
Assert.False(report.RootElement.GetProperty("isFinal").GetBoolean());
Assert.Equal(1, report.RootElement.GetProperty("pendingReviewCount").GetInt32());
Assert.Equal(JsonValueKind.Null, result.GetProperty("isCorrect").ValueKind);
Assert.Equal(JsonValueKind.Null, result.GetProperty("explanation").ValueKind);
}
[Fact]
public async Task Concurrent_submission_creates_only_one_authoritative_report()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
await client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "concurrent-answer",
SelectedOptionIndices = [0]
});
var submissions = await Task.WhenAll(
client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "concurrent-submit-a"
}),
client.PostAsJsonAsync(
"/api/student/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "concurrent-submit-b"
}));
Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.OK);
Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.Conflict);
using var scope = factory.CreateSystemScope("Verify unique authoritative report");
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<TikuDbContext>().PracticeSessionReports
.CountAsync(item => item.PracticeSessionId == answerable.SessionId));
}
[Fact]
public async Task Concurrent_devices_cannot_silently_overwrite_an_answer()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var responses = await Task.WhenAll(
client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "device-a-answer",
SelectedOptionIndices = [0]
}),
client.PostAsJsonAsync(
"/api/student/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 2,
IdempotencyKey = "device-b-answer",
SelectedOptionIndices = [1]
}));
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK);
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict);
using var scope = factory.CreateSystemScope("Verify concurrent answer winner");
var records = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().AnswerRecords
.Where(item => item.PracticeSessionId == answerable.SessionId)
.ToArrayAsync();
Assert.Single(records);
Assert.True(records[0].IsCurrent);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000000";
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Learning Tenant"
},
new User
{
Id = userId,
Phone = phone,
Name = "Learning User"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
return (tenantId, userId, phone);
}
private static async Task<(Guid SessionQuestionId, Guid SessionId, Guid VersionId)> SeedAnswerableQuestionAsync(
ApiTestFactory factory,
(Guid TenantId, Guid UserId, string Phone) seed,
Guid questionId,
string questionType = "choice")
{
var versionId = Guid.NewGuid();
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = questionType,
Status = QuestionStatus.Published
},
new QuestionVersion
{
Id = versionId,
TenantId = seed.TenantId,
QuestionId = questionId,
VersionNo = 1,
Content = "answerable question",
CorrectOptionIndex = questionType == "choice" ? 0 : null
});
var referenceId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var sessionQuestionId = Guid.NewGuid();
await factory.SeedAsync(
new TenantQuestionReference
{
Id = referenceId,
TenantId = seed.TenantId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = questionId,
Source = QuestionSource.Tenant
},
new PracticeSession
{
Id = sessionId,
TenantId = seed.TenantId,
UserId = seed.UserId,
Mode = "single",
QuestionCount = 1,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30)
},
new PracticeSessionQuestion
{
Id = sessionQuestionId,
TenantId = seed.TenantId,
PracticeSessionId = sessionId,
QuestionReferenceId = referenceId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = questionId,
QuestionVersionId = versionId,
Position = 0,
QuestionType = questionType,
ContentSnapshot = "answerable question",
CorrectOptionIndexSnapshot = questionType == "choice" ? 0 : null,
Score = 1
});
return (sessionQuestionId, sessionId, versionId);
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
{
var body = await ReadJsonAsync(response);
return body.RootElement
.GetProperty("items")
.EnumerateArray()
.Select(item => item.Clone())
.ToArray();
}
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
{
using var body = await ReadJsonAsync(response);
return body.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
}