Files
tiku-backend.net/Tiku.IntegrationTests/Api/LearningEndpointTests.cs

455 lines
18 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
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/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();
await factory.SeedAsync(new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var response = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = questionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
});
using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions");
var answer = await ReadJsonAsync(response);
var wrongItems = await ReadItemsAsync(wrongResponse);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(questionId, answer.RootElement.GetProperty("questionId").GetGuid());
Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean());
var wrong = Assert.Single(wrongItems);
Assert.Equal(questionId, wrong.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();
await factory.SeedAsync(
new QuestionCollection
{
Id = collectionId,
TenantId = seed.TenantId,
Name = "基础练习",
Status = ContentStatus.Active
},
new Question
{
Id = firstQuestionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new Question
{
Id = secondQuestionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
CollectionId = collectionId,
QuestionId = firstQuestionId,
SortOrder = 1
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
CollectionId = collectionId,
QuestionId = secondQuestionId,
SortOrder = 2
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var createResponse = await client.PostAsJsonAsync(
"/api/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/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}");
var detail = await ReadJsonAsync(detailResponse);
await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = firstQuestionId,
PracticeSessionId = practiceSessionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = true
});
await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = secondQuestionId,
PracticeSessionId = practiceSessionId,
SelectedOptions = ["B"],
SelfJudgedCorrect = false
});
var submitResponse = await client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId });
var report = await ReadJsonAsync(submitResponse);
var reportResponse = await client.GetAsync($"/api/learning/practice-sessions/report?practiceSessionId={practiceSessionId}");
var reportsResponse = await client.GetAsync("/api/learning/practice-reports");
var historyResponse = await client.GetAsync("/api/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.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/learning/favorites/questions",
new QuestionActionDto { QuestionId = questionId });
var listResponse = await client.GetAsync("/api/learning/favorites/questions");
var itemsAfterAdd = await ReadItemsAsync(listResponse);
var removeResponse = await client.PostAsJsonAsync(
"/api/learning/favorites/questions",
new QuestionActionDto { QuestionId = questionId, Favorite = false });
var emptyResponse = await client.GetAsync("/api/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("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/learning/vocabulary/progress",
new WordProgressDto
{
WordId = wordId,
Status = "learning",
CorrectDelta = 2
});
using var listResponse = await client.GetAsync("/api/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/learning/vocabulary/favorites",
new FavoriteWordDto { WordId = wordId, Note = "重点" });
await client.PostAsJsonAsync(
"/api/learning/vocabulary/favorites",
new FavoriteWordDto { WordId = otherWordId });
using var response = await client.GetAsync($"/api/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();
await factory.SeedAsync(
new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new VocabularyWord
{
Id = wordId,
TenantId = seed.TenantId,
Word = "review",
IsActive = true
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = questionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
});
await client.PostAsJsonAsync(
"/api/learning/vocabulary/progress",
new WordProgressDto
{
WordId = wordId,
Status = "learning",
WrongDelta = 1,
NextReviewAt = DateTimeOffset.UtcNow.AddMinutes(-1)
});
var statsResponse = await client.GetAsync("/api/learning/stats");
var trendResponse = await client.GetAsync("/api/learning/trend?limit=7");
var leaderboardResponse = await client.GetAsync("/api/learning/leaderboard?limit=10");
var wrongPlanResponse = await client.GetAsync("/api/learning/wrong-questions/review-plan");
var wordPlanResponse = await client.GetAsync("/api/learning/vocabulary/review-plan");
var reviewResponse = await client.PostAsJsonAsync(
"/api/learning/vocabulary/review",
new WordReviewDto { WordId = wordId, Result = "correct" });
var wordStatsResponse = await client.GetAsync("/api/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());
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Learning Tenant"
},
new User
{
Id = userId,
Phone = phone,
Name = "Learning User"
},
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
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 JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
}