forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.IntegrationTests.Infrastructure;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -18,7 +22,9 @@ public sealed class ApiTestFactory(
|
||||
IWechatOAuthClient? wechatOAuthClient = null,
|
||||
IObjectStorageService? objectStorageService = null,
|
||||
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
|
||||
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
|
||||
IPaymentProviderGateway? paymentProviderGateway = null,
|
||||
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
|
||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
|
||||
|
||||
@@ -29,6 +35,7 @@ public sealed class ApiTestFactory(
|
||||
foreach (var descriptor in services
|
||||
.Where(descriptor =>
|
||||
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
|
||||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
|
||||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
|
||||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
|
||||
.ToArray())
|
||||
@@ -37,11 +44,13 @@ public sealed class ApiTestFactory(
|
||||
}
|
||||
|
||||
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
|
||||
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
|
||||
services.AddScoped<TenantIsolationSaveChangesInterceptor>();
|
||||
services.AddDbContext<TikuDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||
options.UseNpgsql(dataSource, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
|
||||
if (wechatOAuthClient is not null)
|
||||
@@ -63,17 +72,47 @@ public sealed class ApiTestFactory(
|
||||
{
|
||||
services.AddSingleton(paymentProviderGateway);
|
||||
}
|
||||
|
||||
if (domainOwnershipVerifier is not null)
|
||||
{
|
||||
services.RemoveAll<IDomainOwnershipVerifier>();
|
||||
services.AddSingleton(domainOwnershipVerifier);
|
||||
}
|
||||
|
||||
if (domainGatewayProvisioner is not null)
|
||||
{
|
||||
services.RemoveAll<IDomainGatewayProvisioner>();
|
||||
services.AddSingleton(domainGatewayProvisioner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SeedAsync(params object[] entities)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Integration test fixture seeding");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.AddRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
|
||||
{
|
||||
var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, reason);
|
||||
return scope;
|
||||
}
|
||||
|
||||
public IServiceScope CreateTenantScope(Guid tenantId, string? tenantCode = null)
|
||||
{
|
||||
var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.Initialize(tenantId, tenantCode, TenantResolutionSource.TenantCode);
|
||||
return scope;
|
||||
}
|
||||
|
||||
public async Task SeedQuestionWithVersionAsync(Question question, QuestionVersion version)
|
||||
{
|
||||
question.CurrentVersionId = null;
|
||||
@@ -81,6 +120,8 @@ public sealed class ApiTestFactory(
|
||||
await SeedAsync(version);
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(question.TenantId, "Integration test question version linking");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var persistedQuestion = await dbContext.Questions.SingleAsync(item =>
|
||||
item.TenantId == question.TenantId && item.Id == question.Id);
|
||||
@@ -88,6 +129,24 @@ public sealed class ApiTestFactory(
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<TenantQuestionReference> SeedQuestionReferenceAsync(
|
||||
Guid tenantId,
|
||||
Guid questionOwnerTenantId,
|
||||
Guid questionId,
|
||||
QuestionSource source = QuestionSource.Tenant)
|
||||
{
|
||||
var reference = new TenantQuestionReference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
QuestionOwnerTenantId = questionOwnerTenantId,
|
||||
QuestionId = questionId,
|
||||
Source = source
|
||||
};
|
||||
await SeedAsync(reference);
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task<Guid> SeedActiveSessionAsync(
|
||||
Guid userId,
|
||||
Guid? tenantId = null,
|
||||
@@ -117,6 +176,8 @@ public sealed class ApiTestFactory(
|
||||
});
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
return await dbContext.AuthSessions
|
||||
.Where(session => session.UserId == userId)
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class AssetAccessEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(1, dbContext.ContentAssets.Single(asset => asset.Id == assetId).DownloadCount);
|
||||
Assert.Contains(dbContext.ContentAssetAccessEvents, item =>
|
||||
@@ -251,7 +251,7 @@ public sealed class AssetAccessEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class AssetManagementEndpointTests
|
||||
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal(assetId, listItem.GetProperty("id").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(seed.TenantId, asset.TenantId);
|
||||
@@ -118,7 +118,7 @@ public sealed class AssetManagementEndpointTests
|
||||
Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString());
|
||||
Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus);
|
||||
@@ -299,7 +299,7 @@ public sealed class AssetManagementEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -13,6 +13,60 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantB = await SeedLoginUserAsync(factory);
|
||||
var tenantAId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantAId,
|
||||
Slug = "tenant-a",
|
||||
Name = "Tenant A",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.Saas
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
TenantId = tenantAId,
|
||||
Host = "a.example.test",
|
||||
DomainType = TenantDomainType.Custom,
|
||||
Status = TenantDomainStatus.Active,
|
||||
IsPrimary = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = tenantB.TenantId.ToString("N"),
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
|
||||
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||
jwtRequest.Headers.Host = "a.example.test";
|
||||
jwtRequest.Headers.Authorization = new("Bearer", accessToken);
|
||||
var jwtResponse = await client.SendAsync(jwtRequest);
|
||||
|
||||
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
|
||||
spoofRequest.Headers.Host = "a.example.test";
|
||||
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
|
||||
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var spoofResponse = await client.SendAsync(spoofRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, jwtResponse.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_can_access_current_user_and_tenant()
|
||||
{
|
||||
@@ -24,7 +78,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
@@ -57,7 +111,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/sms",
|
||||
new SmsLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Code = "123456"
|
||||
});
|
||||
@@ -84,7 +138,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
@@ -136,7 +190,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/oauth/wechat-miniapp",
|
||||
new OAuthCodeDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Code = "wx-code"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
@@ -147,7 +201,7 @@ public sealed class AuthEndpointTests
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
var meResponse = await client.GetAsync("/api/me");
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
@@ -204,7 +258,7 @@ public sealed class AuthEndpointTests
|
||||
string phone,
|
||||
string code)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
|
||||
@@ -272,16 +272,16 @@ public sealed class CommerceEndpointTests
|
||||
signatureValid = true
|
||||
};
|
||||
var firstNotify = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
payload);
|
||||
var secondNotify = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
payload);
|
||||
await LoginAsync(client, seed);
|
||||
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var events = dbContext.PaymentEvents.Count(item => item.EventId == "notify-001");
|
||||
|
||||
@@ -302,7 +302,7 @@ public sealed class CommerceEndpointTests
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
var notifyResponse = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
new
|
||||
{
|
||||
eventId = "notify-invalid",
|
||||
@@ -403,7 +403,7 @@ public sealed class CommerceEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ public sealed class CommissionEndpointTests
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" });
|
||||
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Password = "passw0rd!" });
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString());
|
||||
|
||||
@@ -141,7 +141,7 @@ public sealed class ContentManagementEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode);
|
||||
Assert.Equal(collectionId, blueprintJson.RootElement.GetProperty("item").GetProperty("collectionId").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
Assert.Contains(dbContext.QuestionCollectionItems, item => item.QuestionId == questionId);
|
||||
@@ -231,7 +231,7 @@ public sealed class ContentManagementEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -201,15 +201,21 @@ public sealed class ContentNavigationEndpointTests
|
||||
PrimaryCollectionId = collectionId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Archived
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionId = archivedQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
var archivedReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
archivedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = archivedReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = archivedQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
@@ -227,11 +233,17 @@ public sealed class ContentNavigationEndpointTests
|
||||
QuestionId = publishedQuestionId,
|
||||
Content = "题干"
|
||||
});
|
||||
var publishedReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
publishedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = publishedReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = publishedQuestionId,
|
||||
SortOrder = 1,
|
||||
Score = 2
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class CrmEndpointTests
|
||||
Assert.Equal("tenant_secrets:crm:webhook:default", config!.SecretRef);
|
||||
Assert.Equal("RoundRobin", config.AssignmentMode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.TenantSecrets, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -146,7 +146,7 @@ public sealed class CrmEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(collectionId, json.RootElement.GetProperty("item").GetProperty("primaryCollectionId").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.QuestionCollectionItems, item => item.CollectionId == collectionId && item.QuestionId == questionId);
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
@@ -168,7 +168,7 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, postCheckResponse.StatusCode);
|
||||
Assert.NotEqual(Guid.Empty, previewJobId);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(dbContext.ContentImportJobs.Any(item => item.Id == executeJobId && item.InsertedCount == 1));
|
||||
Assert.True(dbContext.Questions.Count(item => item.TenantId == seed.TenantId) >= 2);
|
||||
@@ -274,7 +274,7 @@ public sealed class DirectContentEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -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!"
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ public sealed class PointsEndpointTests
|
||||
Assert.Equal(30, summary.SpentPoints);
|
||||
Assert.Equal(70, summary.BalancePoints);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.Entitlements, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -252,7 +252,7 @@ public sealed class PointsEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ public sealed class ProfileEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode);
|
||||
Assert.Single(feedbacksJson.RootElement.EnumerateArray());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(NotificationStatus.Read, dbContext.UserNotifications.Single(item => item.Id == notificationId).Status);
|
||||
Assert.Single(dbContext.ReportStatusEvents);
|
||||
@@ -237,7 +237,7 @@ public sealed class ProfileEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -97,11 +97,17 @@ public sealed class QuestionBankEndpointTests
|
||||
VersionNo = 2,
|
||||
Content = "题干关键词"
|
||||
});
|
||||
var questionReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
includedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = questionReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = includedQuestionId
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed class ReferralEndpointTests
|
||||
Assert.Equal($"ref={qrcodeItem.RefCode}", qrcodeGenerator.LastRequest.Scene);
|
||||
Assert.Equal("fake-referral-qrcode-generator", qrcodeItem.Metadata.GetProperty("generatedBy").GetString());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.ReferralLeads, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -294,7 +294,7 @@ public sealed class ReferralEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -35,14 +35,15 @@ public sealed class SecurityFoundationTests
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, Guid.NewGuid().ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
||||
]));
|
||||
|
||||
@@ -56,13 +57,15 @@ public sealed class SecurityFoundationTests
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
await using var factory = CreateFactory();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString())
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.Equal(studentUserId, listJson.RootElement.GetProperty("items")[0].GetProperty("userId").GetGuid());
|
||||
Assert.Equal(1, classesJson.RootElement.GetProperty("items")[0].GetProperty("studentCount").GetInt32());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId));
|
||||
Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student));
|
||||
@@ -148,7 +148,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null));
|
||||
}
|
||||
@@ -239,7 +239,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, authProviderResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, secretSettingsResponse.StatusCode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库"));
|
||||
Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic"));
|
||||
@@ -377,7 +377,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.True(notificationsJson.RootElement.GetProperty("items").GetArrayLength() >= 2);
|
||||
Assert.Single(feedbackJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.UserBadges.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId));
|
||||
Assert.True(await dbContext.UserNotifications.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted"));
|
||||
@@ -435,7 +435,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
|
||||
Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedSecret = await dbContext.TenantSecrets
|
||||
.AsNoTracking()
|
||||
@@ -143,7 +143,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(2, codes.Items.Count);
|
||||
Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.Entitlements, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -247,7 +247,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -9,6 +11,59 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class TenantPublicEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Runtime_bootstrap_uses_published_config_etag_and_invalidates_after_publish()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await SeedTenantAsync(factory, tenantId);
|
||||
await factory.SeedAsync(new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ConfigVersion = 1,
|
||||
PublishedBranding = JsonSerializer.SerializeToElement(new { name = "published" }),
|
||||
DraftBranding = JsonSerializer.SerializeToElement(new { name = "published" })
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
firstRequest.Headers.Host = "student.example.test";
|
||||
var first = await client.SendAsync(firstRequest);
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var firstEtag = first.Headers.ETag?.Tag;
|
||||
|
||||
using var notModifiedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
notModifiedRequest.Headers.Host = "student.example.test";
|
||||
notModifiedRequest.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(notModifiedRequest);
|
||||
|
||||
using (var scope = factory.CreateTenantScope(tenantId, "master"))
|
||||
{
|
||||
var service = scope.ServiceProvider.GetRequiredService<ITenantFrontendConfigService>();
|
||||
await service.SaveDraftAsync(
|
||||
tenantId,
|
||||
new TenantFrontendConfigDraft(
|
||||
JsonSerializer.SerializeToElement(new { name = "draft" }),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Array(),
|
||||
JsonDefaults.Array()));
|
||||
await service.PublishAsync(tenantId, 1);
|
||||
}
|
||||
|
||||
using var publishedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
publishedRequest.Headers.Host = "student.example.test";
|
||||
var published = await client.SendAsync(publishedRequest);
|
||||
var publishedBody = await published.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal("published", firstBody.GetProperty("branding").GetProperty("name").GetString());
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, published.StatusCode);
|
||||
Assert.NotEqual(firstEtag, published.Headers.ETag?.Tag);
|
||||
Assert.Equal("draft", publishedBody.GetProperty("branding").GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_returns_public_tenant_configuration_by_tenant_code()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user