forked from xiongyuxing/tiku-backend.net
520 lines
20 KiB
C#
520 lines
20 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.Learning;
|
|
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();
|
|
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/api/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = sessionQuestionId,
|
|
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(sessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
|
|
Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean());
|
|
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"
|
|
});
|
|
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"
|
|
});
|
|
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/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);
|
|
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();
|
|
|
|
await client.PostAsJsonAsync(
|
|
"/api/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = firstSessionQuestionId,
|
|
SelectedOptions = ["A"],
|
|
SelfJudgedCorrect = true
|
|
});
|
|
await client.PostAsJsonAsync(
|
|
"/api/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = secondSessionQuestionId,
|
|
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("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/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();
|
|
var sessionQuestionId = 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/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = sessionQuestionId,
|
|
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";
|
|
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> SeedAnswerableQuestionAsync(
|
|
ApiTestFactory factory,
|
|
(Guid TenantId, Guid UserId, string Phone) seed,
|
|
Guid questionId)
|
|
{
|
|
var versionId = Guid.NewGuid();
|
|
await factory.SeedQuestionWithVersionAsync(
|
|
new Question
|
|
{
|
|
Id = questionId,
|
|
TenantId = seed.TenantId,
|
|
Type = "choice",
|
|
Status = QuestionStatus.Published
|
|
},
|
|
new QuestionVersion
|
|
{
|
|
Id = versionId,
|
|
TenantId = seed.TenantId,
|
|
QuestionId = questionId,
|
|
VersionNo = 1,
|
|
Content = "answerable question"
|
|
});
|
|
|
|
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
|
|
});
|
|
return sessionQuestionId;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
}
|