forked from xiongyuxing/tiku-backend.net
feat: add learning activity endpoints
This commit is contained in:
274
Tiku.IntegrationTests/Api/LearningEndpointTests.cs
Normal file
274
Tiku.IntegrationTests/Api/LearningEndpointTests.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
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 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());
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user