feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -6,6 +6,7 @@ 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;
@@ -32,13 +33,7 @@ public sealed class LearningEndpointTests
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
});
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
@@ -46,7 +41,7 @@ public sealed class LearningEndpointTests
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = questionId,
SessionQuestionId = sessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
});
@@ -55,10 +50,10 @@ public sealed class LearningEndpointTests
var wrongItems = await ReadItemsAsync(wrongResponse);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(questionId, answer.RootElement.GetProperty("questionId").GetGuid());
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("questionId").GetGuid());
Assert.Equal(questionId, wrong.GetProperty("locator").GetProperty("questionId").GetGuid());
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
}
@@ -70,14 +65,9 @@ public sealed class LearningEndpointTests
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
},
var firstVersionId = Guid.NewGuid();
var secondVersionId = Guid.NewGuid();
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = firstQuestionId,
@@ -85,6 +75,15 @@ public sealed class LearningEndpointTests
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,
@@ -92,11 +91,47 @@ public sealed class LearningEndpointTests
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
},
@@ -105,6 +140,8 @@ public sealed class LearningEndpointTests
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
CollectionId = collectionId,
QuestionReferenceId = secondReferenceId,
QuestionOwnerTenantId = seed.TenantId,
QuestionId = secondQuestionId,
SortOrder = 2
});
@@ -124,13 +161,17 @@ public sealed class LearningEndpointTests
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
{
QuestionId = firstQuestionId,
PracticeSessionId = practiceSessionId,
SessionQuestionId = firstSessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = true
});
@@ -138,8 +179,7 @@ public sealed class LearningEndpointTests
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = secondQuestionId,
PracticeSessionId = practiceSessionId,
SessionQuestionId = secondSessionQuestionId,
SelectedOptions = ["B"],
SelfJudgedCorrect = false
});
@@ -199,7 +239,9 @@ public sealed class LearningEndpointTests
Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
Assert.Equal(questionId, Assert.Single(itemsAfterAdd).GetProperty("questionId").GetGuid());
Assert.Equal(
questionId,
Assert.Single(itemsAfterAdd).GetProperty("locator").GetProperty("questionId").GetGuid());
Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode);
Assert.Empty(itemsAfterRemove);
}
@@ -304,14 +346,8 @@ public sealed class LearningEndpointTests
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
var wordId = Guid.NewGuid();
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
await factory.SeedAsync(
new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new VocabularyWord
{
Id = wordId,
@@ -326,7 +362,7 @@ public sealed class LearningEndpointTests
"/api/learning/answers",
new SubmitAnswerDto
{
QuestionId = questionId,
SessionQuestionId = sessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
});
@@ -409,6 +445,64 @@ public sealed class LearningEndpointTests
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)
@@ -417,7 +511,7 @@ public sealed class LearningEndpointTests
"/api/auth/login/password",
new PasswordLoginDto
{
TenantId = seed.TenantId,
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Password = "passw0rd!"
});