feat: establish dotnet engineering foundation

This commit is contained in:
2026-07-27 15:00:14 +08:00
parent 60a376ea0b
commit 28e9a9fa41
36 changed files with 17301 additions and 89 deletions

View File

@@ -7,7 +7,9 @@ using Tiku.Application.Auth;
using Tiku.Application.Growth;
using Tiku.Application.Storage;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.IntegrationTests.Infrastructure;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
@@ -18,7 +20,7 @@ public sealed class ApiTestFactory(
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
{
private readonly string databaseName = Guid.NewGuid().ToString();
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
@@ -34,8 +36,13 @@ public sealed class ApiTestFactory(
services.Remove(descriptor);
}
services.AddDbContext<TikuDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
{
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
});
if (wechatOAuthClient is not null)
{
@@ -67,6 +74,20 @@ public sealed class ApiTestFactory(
await dbContext.SaveChangesAsync();
}
public async Task SeedQuestionWithVersionAsync(Question question, QuestionVersion version)
{
question.CurrentVersionId = null;
await SeedAsync(question);
await SeedAsync(version);
using var scope = Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var persistedQuestion = await dbContext.Questions.SingleAsync(item =>
item.TenantId == question.TenantId && item.Id == question.Id);
persistedQuestion.CurrentVersionId = version.Id;
await dbContext.SaveChangesAsync();
}
public async Task<Guid> SeedActiveSessionAsync(
Guid userId,
Guid? tenantId = null,
@@ -102,4 +123,13 @@ public sealed class ApiTestFactory(
.Select(session => session.Id)
.SingleAsync();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
database.Dispose();
}
}
}

View File

@@ -1,7 +1,9 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests.Api;
@@ -16,6 +18,7 @@ public sealed class AssetEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new ContentAsset
{
Id = Guid.NewGuid(),
@@ -162,6 +165,13 @@ public sealed class AssetEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Question
{
Id = questionId,
TenantId = tenantId,
Type = "choice",
Status = QuestionStatus.Published
},
new VideoExplanation
{
Id = videoId,

View File

@@ -299,12 +299,16 @@ public sealed class CatalogEndpointTests
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
var otherSchoolId = Guid.NewGuid();
var futureExamAt = new DateTimeOffset(DateTimeOffset.UtcNow.Date.AddDays(10), TimeSpan.Zero);
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
new Region { Id = otherRegionId, TenantId = tenantId, Name = "其他地区" },
new School { Id = schoolId, TenantId = tenantId, RegionId = regionId, Name = "测试院校" },
new School { Id = otherSchoolId, TenantId = tenantId, RegionId = otherRegionId, Name = "其他院校" },
new ExamDate
{
Id = Guid.NewGuid(),
@@ -327,8 +331,8 @@ public sealed class CatalogEndpointTests
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = Guid.NewGuid(),
SchoolId = Guid.NewGuid(),
RegionId = otherRegionId,
SchoolId = otherSchoolId,
ExamName = "其他地区学校考试",
ExamAt = futureExamAt.AddDays(2),
IsActive = true
@@ -420,10 +424,12 @@ public sealed class CatalogEndpointTests
{
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "浙江" },
new Region { Id = otherRegionId, TenantId = tenantId, Name = "其他地区" },
new SvipPlan
{
Id = Guid.NewGuid(),
@@ -449,7 +455,7 @@ public sealed class CatalogEndpointTests
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = Guid.NewGuid(),
RegionId = otherRegionId,
Name = "其他地区卡",
PriceCents = 1000,
Days = 7,

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
@@ -17,6 +18,7 @@ public sealed class ContentNavigationEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new ContentEntry
{
Id = Guid.NewGuid(),
@@ -193,23 +195,6 @@ public sealed class ContentNavigationEndpointTests
Status = ContentStatus.Active
},
new Question
{
Id = publishedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
TypeLabel = "单选题",
Status = QuestionStatus.Published,
CurrentVersionId = versionId
},
new QuestionVersion
{
Id = versionId,
TenantId = tenantId,
QuestionId = publishedQuestionId,
Content = "题干"
},
new Question
{
Id = archivedQuestionId,
TenantId = tenantId,
@@ -218,15 +203,6 @@ public sealed class ContentNavigationEndpointTests
Status = QuestionStatus.Archived
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = publishedQuestionId,
SortOrder = 1,
Score = 2
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
@@ -234,6 +210,32 @@ public sealed class ContentNavigationEndpointTests
QuestionId = archivedQuestionId,
SortOrder = 2
});
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = publishedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
TypeLabel = "单选题",
Status = QuestionStatus.Published
},
new QuestionVersion
{
Id = versionId,
TenantId = tenantId,
QuestionId = publishedQuestionId,
Content = "题干"
});
await factory.SeedAsync(new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = publishedQuestionId,
SortOrder = 1,
Score = 2
});
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Configuration;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Infrastructure.Commerce;
namespace Tiku.IntegrationTests.Api;
public sealed class ProductionConfigurationTests
{
[Fact]
public void Database_connection_is_required_outside_development()
{
var configuration = new ConfigurationBuilder().Build();
var exception = Assert.Throws<InvalidOperationException>(() =>
OptionsValidation.ResolveDatabaseConnectionString(configuration, isDevelopment: false));
Assert.Contains("Database connection is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Development_database_default_uses_current_system_user_without_a_password()
{
var configuration = new ConfigurationBuilder().Build();
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
configuration,
isDevelopment: true);
Assert.Contains($"Username={Environment.UserName}", connectionString, StringComparison.Ordinal);
Assert.DoesNotContain("Password=", connectionString, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Production_rejects_the_committed_development_jwt_key()
{
var options = new JwtOptions
{
SigningKey = OptionsValidation.DevelopmentSigningKey
};
Assert.False(OptionsValidation.BeValidJwtOptions(options, isProduction: true));
Assert.True(OptionsValidation.BeValidJwtOptions(options, isProduction: false));
}
[Fact]
public void Tenant_secret_encryption_requires_a_32_byte_base64_key()
{
Assert.False(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions()));
Assert.False(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions
{
KeyId = "key-v1",
MasterKey = Convert.ToBase64String(new byte[16])
}));
Assert.True(TenantSecretEncryptionOptions.BeValid(new TenantSecretEncryptionOptions
{
KeyId = "key-v1",
MasterKey = Convert.ToBase64String(new byte[32])
}));
}
}

View File

@@ -62,7 +62,11 @@ public sealed class ProfileEndpointTests
var seed = await SeedStudentAsync(factory);
var regionId = Guid.NewGuid();
var schoolId = Guid.NewGuid();
var otherRegionId = Guid.NewGuid();
await factory.SeedAsync(
new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" },
new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "其他地区" },
new School { Id = schoolId, TenantId = seed.TenantId, RegionId = regionId, Name = "美术学院" },
new StudentProfile
{
TenantId = seed.TenantId,
@@ -81,7 +85,7 @@ public sealed class ProfileEndpointTests
new ExamDate
{
TenantId = seed.TenantId,
RegionId = Guid.NewGuid(),
RegionId = otherRegionId,
ExamName = "其他地区考试",
ExamAt = DateTimeOffset.UtcNow.AddDays(1)
});

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
@@ -62,8 +63,20 @@ public sealed class QuestionBankEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
new QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" },
new QuestionCollection { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active },
new Question
{
Id = excludedQuestionId,
TenantId = tenantId,
QuestionBankId = bankId,
SubjectId = subjectId,
Type = "choice",
Status = QuestionStatus.Archived
});
await factory.SeedQuestionWithVersionAsync(
new Question
{
Id = includedQuestionId,
@@ -74,8 +87,7 @@ public sealed class QuestionBankEndpointTests
Type = "choice",
TypeLabel = "单选题",
Difficulty = 2,
Status = QuestionStatus.Published,
CurrentVersionId = versionId
Status = QuestionStatus.Published
},
new QuestionVersion
{
@@ -84,23 +96,14 @@ public sealed class QuestionBankEndpointTests
QuestionId = includedQuestionId,
VersionNo = 2,
Content = "题干关键词"
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = includedQuestionId
},
new Question
{
Id = excludedQuestionId,
TenantId = tenantId,
QuestionBankId = bankId,
SubjectId = subjectId,
Type = "choice",
Status = QuestionStatus.Archived
});
await factory.SeedAsync(new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = includedQuestionId
});
using var client = factory.CreateClient();
using var response = await client.GetAsync(

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Text.Json;
using Tiku.Domain.Common;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
@@ -16,6 +17,7 @@ public sealed class StudyContentEndpointTests
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new Region { Id = regionId, TenantId = tenantId, Name = "测试地区" },
new VocabularyUnit
{
Id = Guid.NewGuid(),

View File

@@ -1,6 +1,7 @@
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.Auth;
@@ -93,6 +94,21 @@ public sealed class TenantCommerceEndpointTests
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedSecret = await dbContext.TenantSecrets
.AsNoTracking()
.SingleAsync(item => item.TenantId == seed.TenantId);
var configService = scope.ServiceProvider.GetRequiredService<IPaymentProviderConfigService>();
var resolvedAccount = await configService.GetActiveAccountAsync(seed.TenantId, "wechat_pay");
Assert.Equal("development-v1", storedSecret.EncryptionKeyId);
Assert.NotEmpty(storedSecret.EncryptedPayload);
Assert.Equal(12, storedSecret.EncryptionNonce.Length);
Assert.Equal(16, storedSecret.EncryptionTag.Length);
Assert.Equal("pem", resolvedAccount.SecretPayload.GetProperty("privateKey").GetString());
Assert.Equal("v3", resolvedAccount.SecretPayload.GetProperty("apiV3Key").GetString());
}
[Fact]