1549 lines
67 KiB
C#
1549 lines
67 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.Application.Learning;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Commerce;
|
|
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 V2_session_creation_reserves_manifest_quota_atomically()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLearningUserAsync(factory);
|
|
var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 1);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
Task<HttpResponseMessage> CreateAsync() => client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions",
|
|
new CreatePracticeSessionDto
|
|
{
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = releaseId
|
|
});
|
|
|
|
var responses = await Task.WhenAll(CreateAsync(), CreateAsync());
|
|
|
|
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK);
|
|
var denied = Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Forbidden);
|
|
Assert.Equal("practice_total_quota_exhausted", await ReadProblemCodeAsync(denied));
|
|
using var scope = factory.CreateSystemScope("Verify atomic V2 practice quota");
|
|
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.Equal(1, await db.PracticeSessions.CountAsync(item =>
|
|
item.TenantId == seed.TenantId && item.UserId == seed.UserId && item.ResourceId == releaseId));
|
|
Assert.Equal(1, await db.StudentEntitlementsV2
|
|
.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId)
|
|
.Select(item => item.UsedQuestionCount)
|
|
.SingleAsync());
|
|
var usage = await db.PracticeDailyUsages.SingleAsync(item =>
|
|
item.TenantId == seed.TenantId &&
|
|
item.UserId == seed.UserId &&
|
|
item.ScopeType == PracticeUsageScopeType.ProductManifest);
|
|
Assert.Equal(1, usage.UsedCount);
|
|
Assert.Equal(1, usage.FreeLimit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task V2_session_delivers_prompt_grades_from_snapshot_and_reveals_solution()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLearningUserAsync(factory);
|
|
var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var catalogResponse = await client.GetAsync(
|
|
$"/api/student/catalog/resources?businessLineId={seed.BusinessLineId}");
|
|
var catalog = await ReadJsonAsync(catalogResponse);
|
|
var catalogResource = Assert.Single(catalog.RootElement.GetProperty("resources").EnumerateArray().ToArray());
|
|
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
|
|
Assert.Equal(releaseId, catalogResource.GetProperty("resourceId").GetGuid());
|
|
Assert.Equal("V2 release practice", catalogResource.GetProperty("displayName").GetString());
|
|
|
|
using var createResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions",
|
|
new CreatePracticeSessionDto
|
|
{
|
|
Mode = "chapter",
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = releaseId
|
|
});
|
|
var created = await ReadJsonAsync(createResponse);
|
|
var sessionId = created.RootElement.GetProperty("id").GetGuid();
|
|
using var detailResponse = await client.GetAsync(
|
|
$"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}");
|
|
var detail = await ReadJsonAsync(detailResponse);
|
|
var question = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray());
|
|
var sessionQuestionId = question.GetProperty("sessionQuestionId").GetGuid();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
|
Assert.Equal("1 + 1 = ?", question.GetProperty("content").GetString());
|
|
Assert.NotEqual(Guid.Empty, question.GetProperty("questionAssetId").GetGuid());
|
|
Assert.Empty(detail.RootElement.GetProperty("solutionsBySessionQuestion").EnumerateObject());
|
|
|
|
var answerRequest = new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = sessionQuestionId,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "v2-answer-1",
|
|
SelectedOptionIndices = [0]
|
|
};
|
|
using var answerResponse = await client.PostAsJsonAsync("/api/student/learning/answers", answerRequest);
|
|
using var replayResponse = await client.PostAsJsonAsync("/api/student/learning/answers", answerRequest);
|
|
var answer = await ReadJsonAsync(answerResponse);
|
|
var replay = await ReadJsonAsync(replayResponse);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, answerResponse.StatusCode);
|
|
Assert.Equal(answer.RootElement.GetProperty("id").GetGuid(), replay.RootElement.GetProperty("id").GetGuid());
|
|
Assert.Equal(0, answer.RootElement.GetProperty("solution").GetProperty("correctOptionIndex").GetInt32());
|
|
|
|
using var submitResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = sessionId,
|
|
IdempotencyKey = "v2-submit-1"
|
|
});
|
|
var report = await ReadJsonAsync(submitResponse);
|
|
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
|
|
Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32());
|
|
Assert.Equal(1m, report.RootElement.GetProperty("score").GetDecimal());
|
|
|
|
using var scope = factory.CreateSystemScope("Verify V2 immutable answer replay");
|
|
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.Equal(1, await db.AnswerRecords.CountAsync(item => item.PracticeSessionId == sessionId));
|
|
var locked = await db.PracticeSessionQuestions.SingleAsync(item => item.PracticeSessionId == sessionId);
|
|
Assert.Equal(2, locked.SnapshotVersion);
|
|
Assert.NotEqual(Guid.Empty, locked.QuestionRevisionId);
|
|
Assert.NotEqual(Guid.Empty, locked.QuestionPlacementId);
|
|
Assert.NotEqual(Guid.Empty, locked.AssessmentPolicyVersionId);
|
|
await Assert.ThrowsAnyAsync<Exception>(() => db.QuestionRevisions
|
|
.Where(item => item.Id == locked.QuestionRevisionId)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Explanation, "tampered")));
|
|
await Assert.ThrowsAnyAsync<Exception>(() => db.ContentReleaseQuestions
|
|
.Where(item => item.Id == locked.ContentReleaseQuestionId)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(
|
|
item => item.Status, ContentReleaseQuestionStatus.Retired)));
|
|
var manifestVersionId = await db.StudentEntitlementsV2
|
|
.Where(item => item.UserId == seed.UserId)
|
|
.Select(item => item.ProductAccessManifestVersionId)
|
|
.SingleAsync();
|
|
db.ChangeTracker.Clear();
|
|
db.ProductManifestResources.Add(new ProductManifestResource
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ProductAccessManifestVersionId = manifestVersionId,
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = Guid.NewGuid(),
|
|
DisplayName = "forbidden late child"
|
|
});
|
|
await Assert.ThrowsAnyAsync<Exception>(() => db.SaveChangesAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task V2_class_assignment_is_license_bounded_versioned_and_auditable()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLearningUserAsync(factory);
|
|
var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5);
|
|
var classId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new TenantClass
|
|
{
|
|
Id = classId,
|
|
TenantId = seed.TenantId,
|
|
Code = "v2-class",
|
|
Name = "V2 class"
|
|
},
|
|
new TenantClassMember
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ClassId = classId,
|
|
UserId = seed.UserId,
|
|
MemberType = TenantClassMemberType.Student,
|
|
Status = TenantClassMemberStatus.Active
|
|
});
|
|
|
|
Guid manifestVersionId;
|
|
using (var verification = factory.CreateSystemScope("Resolve V2 manifest fixture"))
|
|
{
|
|
var db = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
manifestVersionId = await db.ProductManifestResources.AsNoTracking()
|
|
.Where(item => item.TenantId == seed.TenantId &&
|
|
item.ResourceType == AccessResourceType.ContentRelease &&
|
|
item.ResourceId == releaseId)
|
|
.Select(item => item.ProductAccessManifestVersionId)
|
|
.SingleAsync();
|
|
}
|
|
|
|
ClassAssignmentGrantItem created;
|
|
using (var scope = factory.CreateTenantScope(seed.TenantId, seed.TenantId.ToString("N")))
|
|
{
|
|
var service = scope.ServiceProvider.GetRequiredService<IV2LearningAccessAdministrationService>();
|
|
created = await service.UpsertClassGrantAsync(
|
|
seed.TenantId,
|
|
seed.UserId,
|
|
new UpsertClassAssignmentGrantCommand(
|
|
null,
|
|
classId,
|
|
manifestVersionId,
|
|
AccessResourceType.ContentRelease,
|
|
releaseId,
|
|
DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
DateTimeOffset.UtcNow.AddDays(7)));
|
|
}
|
|
|
|
Assert.Equal(ClassAssignmentGrantStatus.Active, created.Status);
|
|
Assert.Equal(seed.UserId, created.CreatedBy);
|
|
ClassAssignmentGrantItem revoked;
|
|
using (var scope = factory.CreateTenantScope(seed.TenantId, seed.TenantId.ToString("N")))
|
|
{
|
|
revoked = await scope.ServiceProvider.GetRequiredService<IV2LearningAccessAdministrationService>()
|
|
.RevokeClassGrantAsync(
|
|
seed.TenantId,
|
|
seed.UserId,
|
|
classId,
|
|
created.Id,
|
|
"课程调整");
|
|
}
|
|
|
|
Assert.Equal(ClassAssignmentGrantStatus.Revoked, revoked.Status);
|
|
Assert.Equal(seed.UserId, revoked.RevokedBy);
|
|
Assert.Equal("课程调整", revoked.RevokedReason);
|
|
using var finalVerification = factory.CreateSystemScope("Verify V2 class grant version bump");
|
|
var finalDb = finalVerification.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var accessVersion = await finalDb.LearningAccessVersions.AsNoTracking().SingleAsync(item =>
|
|
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
|
|
Assert.True(accessVersion.GrantVersion >= 3);
|
|
}
|
|
|
|
[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 releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var answerable = await CreateV2SessionAsync(client, releaseId);
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
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,
|
|
IdempotencyKey = "wrong-submit-1"
|
|
});
|
|
using var beforeProjectionResponse = await client.GetAsync("/api/student/learning/wrong-questions");
|
|
Assert.Empty(await ReadItemsAsync(beforeProjectionResponse));
|
|
Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "wrong-question-worker"));
|
|
using var wrongResponse = await client.GetAsync("/api/student/learning/wrong-questions");
|
|
var answer = await ReadJsonAsync(response);
|
|
var wrongItems = await ReadItemsAsync(wrongResponse);
|
|
|
|
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
|
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(answerable.QuestionAssetId, wrong.GetProperty("questionAssetId").GetGuid());
|
|
Assert.NotEqual(Guid.Empty, wrong.GetProperty("lastQuestionRevisionId").GetGuid());
|
|
Assert.NotEqual(Guid.Empty, wrong.GetProperty("lastQuestionPlacementId").GetGuid());
|
|
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
|
|
|
|
using (var scope = factory.CreateSystemScope("Replay projected learning event"))
|
|
{
|
|
await scope.ServiceProvider.GetRequiredService<TikuDbContext>().LearningOutboxMessages
|
|
.Where(item => item.TenantId == seed.TenantId &&
|
|
item.EventType == "practice_session_submitted")
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.ProcessedAt, (DateTimeOffset?)null)
|
|
.SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "wrong-question-replay-worker"));
|
|
using var replayedWrongResponse = await client.GetAsync("/api/student/learning/wrong-questions");
|
|
var replayedWrong = Assert.Single(await ReadItemsAsync(replayedWrongResponse));
|
|
Assert.Equal(1, replayedWrong.GetProperty("wrongCount").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Wrong_question_projection_is_order_independent()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLearningUserAsync(factory);
|
|
var releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var first = await CreateV2SessionAsync(client, releaseId);
|
|
var second = await CreateV2SessionAsync(client, releaseId);
|
|
|
|
await SubmitWrongSessionAsync(client, first.SessionQuestionId, first.SessionId, "ordered-first");
|
|
await SubmitWrongSessionAsync(client, second.SessionQuestionId, second.SessionId, "ordered-second");
|
|
|
|
DateTimeOffset latestSubmittedAt;
|
|
using (var scope = factory.CreateSystemScope("Force reverse learning event delivery"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var reports = await dbContext.PracticeSessionReports.AsNoTracking()
|
|
.Where(item => item.PracticeSessionId == first.SessionId || item.PracticeSessionId == second.SessionId)
|
|
.ToArrayAsync();
|
|
var firstReport = reports.Single(item => item.PracticeSessionId == first.SessionId);
|
|
latestSubmittedAt = reports.Max(item => item.SubmittedAt);
|
|
await dbContext.LearningOutboxMessages
|
|
.Where(item => item.AggregateId == firstReport.Id)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow.AddHours(1)));
|
|
}
|
|
|
|
Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "reverse-newer-first"));
|
|
using (var scope = factory.CreateSystemScope("Release older learning event"))
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
await dbContext.LearningOutboxMessages
|
|
.Where(item => item.ProcessedAt == null)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.AvailableAt, DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "reverse-older-second"));
|
|
using (var scope = factory.CreateSystemScope("Verify order-independent wrong question projection"))
|
|
{
|
|
var wrong = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().WrongQuestions
|
|
.SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId);
|
|
Assert.Equal(first.QuestionAssetId, wrong.QuestionAssetId);
|
|
Assert.Equal(2, wrong.WrongCount);
|
|
Assert.Equal(latestSubmittedAt, wrong.LastWrongAt);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failed_learning_outbox_message_is_released_for_retry()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedLearningUserAsync(factory);
|
|
var messageId = Guid.NewGuid();
|
|
await factory.SeedAsync(new LearningOutboxMessage
|
|
{
|
|
Id = messageId,
|
|
TenantId = seed.TenantId,
|
|
EventType = "unsupported_learning_event",
|
|
AggregateId = Guid.NewGuid(),
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
AvailableAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
Assert.Equal(0, await ProcessLearningOutboxAsync(factory, "poison-learning-event"));
|
|
|
|
using var scope = factory.CreateSystemScope("Verify learning outbox retry state");
|
|
var stored = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().LearningOutboxMessages
|
|
.SingleAsync(item => item.Id == messageId);
|
|
Assert.Equal(1, stored.AttemptCount);
|
|
Assert.Null(stored.ProcessedAt);
|
|
Assert.Null(stored.DeadLetteredAt);
|
|
Assert.Null(stored.LockedBy);
|
|
Assert.Null(stored.LockExpiresAt);
|
|
Assert.NotNull(stored.LastError);
|
|
Assert.True(stored.AvailableAt > stored.OccurredAt);
|
|
}
|
|
|
|
[Fact(Skip = "V1 question collections were retired by the Content Domain V2 cutover.")]
|
|
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
|
|
});
|
|
await SeedCollectionAccessAsync(factory, seed, collectionId);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var createResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions",
|
|
new CreatePracticeSessionDto
|
|
{
|
|
Mode = "chapter",
|
|
ResourceType = AccessResourceType.CollectionRelease,
|
|
ResourceId = collectionId
|
|
});
|
|
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,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "practice-first-answer",
|
|
SelectedOptionIndices = [0]
|
|
});
|
|
var secondAnswerResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = secondSessionQuestionId,
|
|
ClientSequence = 2,
|
|
IdempotencyKey = "practice-second-answer",
|
|
SelectedOptionIndices = [1]
|
|
});
|
|
var submitResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = practiceSessionId,
|
|
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 releaseId = await SeedV2ReleaseAccessAsync(factory, seed, dailyQuestionLimit: 5);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var createResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions",
|
|
new CreatePracticeSessionDto
|
|
{
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = releaseId
|
|
});
|
|
var created = await ReadJsonAsync(createResponse);
|
|
var sessionId = created.RootElement.GetProperty("id").GetGuid();
|
|
using var detailResponse = await client.GetAsync(
|
|
$"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}");
|
|
var detail = await ReadJsonAsync(detailResponse);
|
|
var delivered = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray());
|
|
var sessionQuestionId = delivered.GetProperty("sessionQuestionId").GetGuid();
|
|
var questionAssetId = delivered.GetProperty("questionAssetId").GetGuid();
|
|
|
|
var addResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/favorites/questions",
|
|
new QuestionActionDto { SessionQuestionId = sessionQuestionId });
|
|
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 { SessionQuestionId = sessionQuestionId, 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(
|
|
questionAssetId,
|
|
Assert.Single(itemsAfterAdd).GetProperty("questionAssetId").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,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "stats-wrong-answer",
|
|
SelectedOptionIndices = [1]
|
|
});
|
|
await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = answerable.SessionId,
|
|
IdempotencyKey = "stats-wrong-submit"
|
|
});
|
|
Assert.Equal(1, await ProcessLearningOutboxAsync(factory, "learning-stats-worker"));
|
|
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,
|
|
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",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = " revision-answer-1 ",
|
|
SelectedOptionIndices = [1]
|
|
});
|
|
var conflict = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "revision-answer-1",
|
|
SelectedOptionIndices = [0]
|
|
});
|
|
var revision = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
ClientSequence = 2,
|
|
IdempotencyKey = "revision-answer-2",
|
|
SelectedOptionIndices = [0]
|
|
});
|
|
var stale = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
ClientSequence = 2,
|
|
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_client_sequence_conflict", await ReadProblemCodeAsync(stale));
|
|
|
|
using var scope = factory.CreateSystemScope("Verify immutable answer revisions");
|
|
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var records = await db.AnswerRecords
|
|
.Where(item => item.PracticeSessionId == answerable.SessionId)
|
|
.OrderBy(item => item.Revision)
|
|
.ToArrayAsync();
|
|
Assert.Equal(2, records.Length);
|
|
Assert.Equal(2, records.Select(item => item.IdempotencyKey).Distinct().Count());
|
|
var current = await db.CurrentAnswers.SingleAsync(item =>
|
|
item.PracticeSessionId == answerable.SessionId &&
|
|
item.SessionQuestionId == answerable.SessionQuestionId);
|
|
Assert.Equal(records[1].Id, current.AnswerRecordId);
|
|
Assert.Equal(2, current.Revision);
|
|
Assert.Equal(AnswerGradingStatus.Correct, records[1].GradingStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Session_detail_uses_locked_immutable_delivery_version()
|
|
{
|
|
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("Publish a new question version after session creation"))
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var version = await db.QuestionVersions.SingleAsync(item => item.Id == answerable.VersionId);
|
|
var next = new QuestionVersion
|
|
{
|
|
TenantId = version.TenantId,
|
|
QuestionId = version.QuestionId,
|
|
VersionNo = version.VersionNo + 1,
|
|
QuestionType = version.QuestionType,
|
|
Content = "new delivery content",
|
|
Explanation = "must remain hidden",
|
|
CorrectOptionIndex = 1
|
|
};
|
|
db.QuestionVersions.Add(next);
|
|
var question = await db.Questions.SingleAsync(item =>
|
|
item.TenantId == version.TenantId && item.Id == version.QuestionId);
|
|
question.CurrentVersionId = next.Id;
|
|
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,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "subjective-answer",
|
|
AnswerText = "student response"
|
|
});
|
|
var submit = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = answerable.SessionId,
|
|
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,
|
|
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,
|
|
IdempotencyKey = "concurrent-submit-a"
|
|
}),
|
|
client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = answerable.SessionId,
|
|
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,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = "device-a-answer",
|
|
SelectedOptionIndices = [0]
|
|
}),
|
|
client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = answerable.SessionQuestionId,
|
|
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);
|
|
var current = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().CurrentAnswers
|
|
.SingleAsync(item => item.PracticeSessionId == answerable.SessionId);
|
|
Assert.Equal(records[0].Id, current.AnswerRecordId);
|
|
}
|
|
|
|
private static async Task<(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId)> SeedLearningUserAsync(
|
|
ApiTestFactory factory)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var businessLineId = Guid.NewGuid();
|
|
var licenseId = Guid.NewGuid();
|
|
var phone = $"139{Random.Shared.Next(10_000_000, 99_999_999)}";
|
|
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
|
|
},
|
|
new BusinessLine
|
|
{
|
|
Id = businessLineId,
|
|
Code = $"national-{tenantId:N}",
|
|
Name = "全国业务测试",
|
|
RegionAccessStrategy = LearningRegionAccessStrategy.NationalOnly
|
|
},
|
|
new TenantLearningLicense
|
|
{
|
|
Id = licenseId,
|
|
TenantId = tenantId,
|
|
BusinessLineId = businessLineId,
|
|
IncludesNational = true,
|
|
StartsAt = DateTimeOffset.UtcNow.AddDays(-1)
|
|
},
|
|
new LearningAccessVersion
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId
|
|
});
|
|
|
|
return (tenantId, userId, phone, businessLineId);
|
|
}
|
|
|
|
private static async Task<Guid> SeedV2ReleaseAccessAsync(
|
|
ApiTestFactory factory,
|
|
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed,
|
|
int dailyQuestionLimit)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var licenseId = Guid.NewGuid();
|
|
var profileId = Guid.NewGuid();
|
|
var profileVersionId = Guid.NewGuid();
|
|
var curriculumId = Guid.NewGuid();
|
|
var curriculumVersionId = Guid.NewGuid();
|
|
var curriculumNodeId = Guid.NewGuid();
|
|
var assessmentPolicyId = Guid.NewGuid();
|
|
var assessmentPolicyVersionId = Guid.NewGuid();
|
|
var questionAssetId = Guid.NewGuid();
|
|
var questionRevisionId = Guid.NewGuid();
|
|
var placementId = Guid.NewGuid();
|
|
var releaseId = Guid.NewGuid();
|
|
var segmentId = Guid.NewGuid();
|
|
var releaseQuestionId = Guid.NewGuid();
|
|
var productId = Guid.NewGuid();
|
|
var manifestId = Guid.NewGuid();
|
|
var manifestVersionId = Guid.NewGuid();
|
|
var entitlementId = Guid.NewGuid();
|
|
var fingerprint = new string('a', 64);
|
|
|
|
await factory.SeedAsync(
|
|
new TenantBusinessLicense
|
|
{
|
|
Id = licenseId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
StartsAt = now.AddDays(-1)
|
|
},
|
|
new ExamTargetProfile
|
|
{
|
|
Id = profileId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
Code = $"profile-{profileId:N}",
|
|
Name = "V2 target"
|
|
},
|
|
new ExamTargetProfileVersion
|
|
{
|
|
Id = profileVersionId,
|
|
ExamTargetProfileId = profileId,
|
|
DisplayName = "V2 target 2027",
|
|
Status = ContentDefinitionStatus.Published,
|
|
PublishedAt = now
|
|
},
|
|
new Curriculum
|
|
{
|
|
Id = curriculumId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
Code = $"curriculum-{curriculumId:N}",
|
|
Name = "V2 curriculum"
|
|
},
|
|
new CurriculumVersion
|
|
{
|
|
Id = curriculumVersionId,
|
|
TenantId = seed.TenantId,
|
|
CurriculumId = curriculumId,
|
|
Name = "V2 curriculum 2027",
|
|
Status = ContentDefinitionStatus.Published,
|
|
PublishedAt = now
|
|
},
|
|
new CurriculumNode
|
|
{
|
|
Id = curriculumNodeId,
|
|
TenantId = seed.TenantId,
|
|
CurriculumVersionId = curriculumVersionId,
|
|
Code = "math",
|
|
Name = "数学",
|
|
Kind = CurriculumNodeKind.Subject
|
|
},
|
|
new AssessmentPolicy
|
|
{
|
|
Id = assessmentPolicyId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
Code = $"policy-{assessmentPolicyId:N}",
|
|
Name = "Objective"
|
|
},
|
|
new AssessmentPolicyVersion
|
|
{
|
|
Id = assessmentPolicyVersionId,
|
|
TenantId = seed.TenantId,
|
|
AssessmentPolicyId = assessmentPolicyId,
|
|
DefaultScore = 1,
|
|
Status = ContentDefinitionStatus.Published,
|
|
PublishedAt = now
|
|
},
|
|
new QuestionAsset
|
|
{
|
|
Id = questionAssetId,
|
|
TenantId = seed.TenantId,
|
|
DeliveryFingerprint = fingerprint,
|
|
Status = QuestionAssetStatus.Published
|
|
},
|
|
new LearningProduct
|
|
{
|
|
Id = productId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
Code = $"product-{productId:N}",
|
|
Name = "V2 product"
|
|
},
|
|
new ProductAccessManifest
|
|
{
|
|
Id = manifestId,
|
|
TenantId = seed.TenantId,
|
|
LearningProductId = productId
|
|
});
|
|
|
|
await factory.SeedAsync(
|
|
new QuestionRevision
|
|
{
|
|
Id = questionRevisionId,
|
|
TenantId = seed.TenantId,
|
|
QuestionAssetId = questionAssetId,
|
|
NormalizedContent = "one plus one",
|
|
Content = "1 + 1 = ?",
|
|
Options = JsonSerializer.SerializeToElement(new[] { "2", "3" }),
|
|
CorrectOptionIndex = 0,
|
|
DeliveryFingerprint = fingerprint
|
|
},
|
|
new QuestionPlacement
|
|
{
|
|
Id = placementId,
|
|
TenantId = seed.TenantId,
|
|
QuestionAssetOwnerTenantId = seed.TenantId,
|
|
QuestionAssetId = questionAssetId,
|
|
CurriculumVersionId = curriculumVersionId,
|
|
CurriculumNodeId = curriculumNodeId,
|
|
AssessmentPolicyVersionId = assessmentPolicyVersionId,
|
|
Status = QuestionPlacementStatus.Active
|
|
},
|
|
new ContentRelease
|
|
{
|
|
Id = releaseId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
CurriculumVersionId = curriculumVersionId,
|
|
Name = "V2 release",
|
|
SourceFingerprint = fingerprint,
|
|
Status = ContentReleaseStatus.Compiling
|
|
},
|
|
new ProductAccessManifestVersion
|
|
{
|
|
Id = manifestVersionId,
|
|
TenantId = seed.TenantId,
|
|
ProductAccessManifestId = manifestId,
|
|
Status = AccessManifestStatus.Draft,
|
|
MaxActiveTargets = 1,
|
|
DailyQuestionLimit = dailyQuestionLimit,
|
|
TotalQuestionLimit = dailyQuestionLimit
|
|
});
|
|
|
|
await factory.SeedAsync(
|
|
new AudienceSegment
|
|
{
|
|
Id = segmentId,
|
|
TenantId = seed.TenantId,
|
|
ContentReleaseId = releaseId,
|
|
RuleHash = fingerprint
|
|
},
|
|
new TenantLicensedTarget
|
|
{
|
|
TenantId = seed.TenantId,
|
|
TenantBusinessLicenseId = licenseId,
|
|
ExamTargetProfileVersionId = profileVersionId,
|
|
IsBaseTarget = true,
|
|
StartsAt = now.AddDays(-1)
|
|
},
|
|
new StudentTargetSelectionHistory
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
ExamTargetProfileVersionId = profileVersionId,
|
|
Role = TargetSelectionRole.Primary,
|
|
EntitlementId = entitlementId,
|
|
SourceType = "student_entitlement",
|
|
SourceId = entitlementId,
|
|
EffectiveAt = now.AddDays(-1)
|
|
},
|
|
new ProductManifestRelease
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ProductAccessManifestVersionId = manifestVersionId,
|
|
ContentOwnerTenantId = seed.TenantId,
|
|
ContentReleaseId = releaseId
|
|
},
|
|
new ProductManifestTarget
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ProductAccessManifestVersionId = manifestVersionId,
|
|
ExamTargetProfileVersionId = profileVersionId,
|
|
MayBePrimary = true
|
|
},
|
|
new ProductManifestResource
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ProductAccessManifestVersionId = manifestVersionId,
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = releaseId,
|
|
DisplayName = "V2 release practice",
|
|
SortOrder = 10
|
|
},
|
|
new StudentEntitlement
|
|
{
|
|
Id = entitlementId,
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
LearningProductId = productId,
|
|
ProductAccessManifestVersionId = manifestVersionId,
|
|
StartsAt = now.AddDays(-1),
|
|
EndsAt = now.AddDays(30),
|
|
SourceType = "integration_test"
|
|
});
|
|
|
|
await factory.SeedAsync(
|
|
new AudienceSegmentMember
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ContentReleaseId = releaseId,
|
|
AudienceSegmentId = segmentId,
|
|
ExamTargetProfileVersionId = profileVersionId
|
|
},
|
|
new ContentReleaseQuestion
|
|
{
|
|
Id = releaseQuestionId,
|
|
TenantId = seed.TenantId,
|
|
ContentReleaseId = releaseId,
|
|
AudienceSegmentId = segmentId,
|
|
CurriculumNodeId = curriculumNodeId,
|
|
QuestionPlacementId = placementId,
|
|
QuestionAssetOwnerTenantId = seed.TenantId,
|
|
QuestionAssetId = questionAssetId,
|
|
QuestionRevisionId = questionRevisionId,
|
|
AssessmentPolicyVersionId = assessmentPolicyVersionId,
|
|
QuestionType = "choice"
|
|
});
|
|
|
|
using var scope = factory.CreateSystemScope("Finalize V2 fixture current revisions");
|
|
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
await db.QuestionAssets.Where(item => item.Id == questionAssetId)
|
|
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.CurrentRevisionId, questionRevisionId));
|
|
await db.ContentReleases.Where(item => item.Id == releaseId)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.Status, ContentReleaseStatus.Published)
|
|
.SetProperty(item => item.PublishedAt, now));
|
|
await db.ProductAccessManifestVersions.Where(item => item.Id == manifestVersionId)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.Status, AccessManifestStatus.Published)
|
|
.SetProperty(item => item.PublishedAt, now));
|
|
return releaseId;
|
|
}
|
|
|
|
private static async Task<(Guid SessionQuestionId, Guid SessionId, Guid VersionId, Guid ReferenceId)>
|
|
SeedAnswerableQuestionAsync(
|
|
ApiTestFactory factory,
|
|
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) 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,
|
|
QuestionType = questionType,
|
|
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,
|
|
AccessGrantVersion = 1,
|
|
StrongRevocationVersion = 1,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30)
|
|
},
|
|
new PracticeSessionQuestion
|
|
{
|
|
Id = sessionQuestionId,
|
|
TenantId = seed.TenantId,
|
|
PracticeSessionId = sessionId,
|
|
QuestionOwnerTenantId = seed.TenantId,
|
|
Position = 0,
|
|
QuestionType = questionType,
|
|
Score = 1
|
|
});
|
|
return (sessionQuestionId, sessionId, versionId, referenceId);
|
|
}
|
|
|
|
private static async Task<(Guid SessionQuestionId, Guid SessionId)> SeedAdditionalAnswerableSessionAsync(
|
|
ApiTestFactory factory,
|
|
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed,
|
|
Guid questionId,
|
|
Guid versionId,
|
|
Guid referenceId)
|
|
{
|
|
var sessionId = Guid.NewGuid();
|
|
var sessionQuestionId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new PracticeSession
|
|
{
|
|
Id = sessionId,
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
Mode = "single",
|
|
QuestionCount = 1,
|
|
AccessGrantVersion = 1,
|
|
StrongRevocationVersion = 1,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30)
|
|
},
|
|
new PracticeSessionQuestion
|
|
{
|
|
Id = sessionQuestionId,
|
|
TenantId = seed.TenantId,
|
|
PracticeSessionId = sessionId,
|
|
QuestionOwnerTenantId = seed.TenantId,
|
|
Position = 0,
|
|
QuestionType = "choice",
|
|
Score = 1
|
|
});
|
|
return (sessionQuestionId, sessionId);
|
|
}
|
|
|
|
private static async Task SubmitWrongSessionAsync(
|
|
HttpClient client,
|
|
Guid sessionQuestionId,
|
|
Guid sessionId,
|
|
string keyPrefix)
|
|
{
|
|
using var answer = await client.PostAsJsonAsync(
|
|
"/api/student/learning/answers",
|
|
new SubmitAnswerDto
|
|
{
|
|
SessionQuestionId = sessionQuestionId,
|
|
ClientSequence = 1,
|
|
IdempotencyKey = $"{keyPrefix}-answer",
|
|
SelectedOptionIndices = [1]
|
|
});
|
|
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
|
using var submit = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions/submit",
|
|
new SubmitPracticeSessionDto
|
|
{
|
|
PracticeSessionId = sessionId,
|
|
IdempotencyKey = $"{keyPrefix}-submit"
|
|
});
|
|
Assert.Equal(HttpStatusCode.OK, submit.StatusCode);
|
|
}
|
|
|
|
private static async Task SeedCollectionAccessAsync(
|
|
ApiTestFactory factory,
|
|
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed,
|
|
Guid collectionId)
|
|
{
|
|
var productId = Guid.NewGuid();
|
|
var sliceId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new ContentSlice
|
|
{
|
|
Id = sliceId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
RegionScope = LearningRegionScopeKind.National,
|
|
ResourceType = LearningContentResourceType.Collection,
|
|
ResourceId = collectionId,
|
|
Status = ContentSliceStatus.Active
|
|
},
|
|
new LearningProduct
|
|
{
|
|
Id = productId,
|
|
TenantId = seed.TenantId,
|
|
BusinessLineId = seed.BusinessLineId,
|
|
Code = "full-collection",
|
|
Name = "完整题集"
|
|
},
|
|
new LearningProductScope
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ProductId = productId,
|
|
ContentSliceOwnerTenantId = seed.TenantId,
|
|
ContentSliceId = sliceId
|
|
},
|
|
new Entitlement
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = seed.UserId,
|
|
LearningProductId = productId,
|
|
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
Status = EntitlementStatus.Active,
|
|
SourceType = "integration_test"
|
|
});
|
|
}
|
|
|
|
private static async Task LoginAsync(
|
|
HttpClient client,
|
|
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed)
|
|
{
|
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
|
}
|
|
|
|
private static async Task<(Guid SessionId, Guid SessionQuestionId, Guid QuestionAssetId)> CreateV2SessionAsync(
|
|
HttpClient client,
|
|
Guid releaseId)
|
|
{
|
|
using var createResponse = await client.PostAsJsonAsync(
|
|
"/api/student/learning/practice-sessions",
|
|
new CreatePracticeSessionDto
|
|
{
|
|
ResourceType = AccessResourceType.ContentRelease,
|
|
ResourceId = releaseId
|
|
});
|
|
createResponse.EnsureSuccessStatusCode();
|
|
using var created = await ReadJsonAsync(createResponse);
|
|
var sessionId = created.RootElement.GetProperty("id").GetGuid();
|
|
using var detailResponse = await client.GetAsync(
|
|
$"/api/student/learning/practice-sessions/detail?practiceSessionId={sessionId}");
|
|
detailResponse.EnsureSuccessStatusCode();
|
|
using var detail = await ReadJsonAsync(detailResponse);
|
|
var question = Assert.Single(detail.RootElement.GetProperty("questions").EnumerateArray().ToArray());
|
|
return (
|
|
sessionId,
|
|
question.GetProperty("sessionQuestionId").GetGuid(),
|
|
question.GetProperty("questionAssetId").GetGuid());
|
|
}
|
|
|
|
private static async Task<int> ProcessLearningOutboxAsync(ApiTestFactory factory, string workerId)
|
|
{
|
|
using var scope = factory.CreateSystemScope($"Process learning outbox with {workerId}");
|
|
return await scope.ServiceProvider.GetRequiredService<ILearningOutboxProcessor>()
|
|
.ProcessPendingAsync(workerId, 10);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|