forked from xiongyuxing/tiku-backend.net
feat: establish dotnet engineering foundation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
61
Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs
Normal file
61
Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs
Normal 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])
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
182
Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs
Normal file
182
Tiku.IntegrationTests/Infrastructure/PostgresTestDatabase.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Infrastructure;
|
||||
|
||||
internal sealed class PostgresTestDatabase : IDisposable
|
||||
{
|
||||
private const string DatabasePrefix = "tiku_it_";
|
||||
private static readonly Lazy<PostgresTestDatabaseTemplate> Template = new(
|
||||
PostgresTestDatabaseTemplate.Create,
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
private readonly string adminConnectionString;
|
||||
private bool disposed;
|
||||
|
||||
private PostgresTestDatabase(string adminConnectionString, string databaseName, string connectionString)
|
||||
{
|
||||
this.adminConnectionString = adminConnectionString;
|
||||
DatabaseName = databaseName;
|
||||
ConnectionString = connectionString;
|
||||
}
|
||||
|
||||
public string DatabaseName { get; }
|
||||
|
||||
public string ConnectionString { get; }
|
||||
|
||||
public static PostgresTestDatabase Create()
|
||||
{
|
||||
var template = Template.Value;
|
||||
var adminConnectionString = template.AdminConnectionString;
|
||||
var databaseName = $"{DatabasePrefix}{Guid.NewGuid():N}";
|
||||
var adminBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString);
|
||||
|
||||
using (var adminConnection = new NpgsqlConnection(adminBuilder.ConnectionString))
|
||||
{
|
||||
adminConnection.Open();
|
||||
using var createDatabase = adminConnection.CreateCommand();
|
||||
createDatabase.CommandText =
|
||||
$"CREATE DATABASE {QuoteIdentifier(databaseName)} TEMPLATE {QuoteIdentifier(template.DatabaseName)}";
|
||||
createDatabase.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var databaseBuilder = new NpgsqlConnectionStringBuilder(adminBuilder.ConnectionString)
|
||||
{
|
||||
Database = databaseName,
|
||||
Pooling = true,
|
||||
MaxPoolSize = 10
|
||||
};
|
||||
var database = new PostgresTestDatabase(
|
||||
adminBuilder.ConnectionString,
|
||||
databaseName,
|
||||
databaseBuilder.ConnectionString);
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
var builder = new NpgsqlConnectionStringBuilder(adminConnectionString);
|
||||
using var adminConnection = new NpgsqlConnection(builder.ConnectionString);
|
||||
adminConnection.Open();
|
||||
|
||||
using (var terminateConnections = adminConnection.CreateCommand())
|
||||
{
|
||||
terminateConnections.CommandText =
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()";
|
||||
terminateConnections.Parameters.AddWithValue(DatabaseName);
|
||||
terminateConnections.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var dropDatabase = adminConnection.CreateCommand();
|
||||
dropDatabase.CommandText = $"DROP DATABASE IF EXISTS {QuoteIdentifier(DatabaseName)}";
|
||||
dropDatabase.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
internal static string QuoteIdentifier(string identifier)
|
||||
{
|
||||
if (!identifier.StartsWith(DatabasePrefix, StringComparison.Ordinal) ||
|
||||
identifier.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '_'))
|
||||
{
|
||||
throw new InvalidOperationException("Refusing to use an unsafe integration-test database name.");
|
||||
}
|
||||
|
||||
return $"\"{identifier}\"";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PostgresTestDatabaseTemplate : IDisposable
|
||||
{
|
||||
private const string TemplatePrefix = "tiku_it_template_";
|
||||
private bool disposed;
|
||||
|
||||
private PostgresTestDatabaseTemplate(string adminConnectionString, string databaseName)
|
||||
{
|
||||
AdminConnectionString = adminConnectionString;
|
||||
DatabaseName = databaseName;
|
||||
}
|
||||
|
||||
public string AdminConnectionString { get; }
|
||||
|
||||
public string DatabaseName { get; }
|
||||
|
||||
public static PostgresTestDatabaseTemplate Create()
|
||||
{
|
||||
var adminConnectionString = Environment.GetEnvironmentVariable("TIKU_TEST_POSTGRES_ADMIN") ??
|
||||
$"Host=localhost;Database=postgres;Username={Environment.UserName};Pooling=false;Timeout=5;Command Timeout=60";
|
||||
var adminBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString);
|
||||
if (!string.Equals(adminBuilder.Database, "postgres", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"TIKU_TEST_POSTGRES_ADMIN must target the postgres maintenance database.");
|
||||
}
|
||||
|
||||
var databaseName = $"{TemplatePrefix}{Environment.ProcessId}_{Guid.NewGuid():N}";
|
||||
var template = new PostgresTestDatabaseTemplate(adminBuilder.ConnectionString, databaseName);
|
||||
|
||||
try
|
||||
{
|
||||
using (var adminConnection = new NpgsqlConnection(template.AdminConnectionString))
|
||||
{
|
||||
adminConnection.Open();
|
||||
using var createDatabase = adminConnection.CreateCommand();
|
||||
createDatabase.CommandText =
|
||||
$"CREATE DATABASE {PostgresTestDatabase.QuoteIdentifier(databaseName)}";
|
||||
createDatabase.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var databaseBuilder = new NpgsqlConnectionStringBuilder(template.AdminConnectionString)
|
||||
{
|
||||
Database = databaseName,
|
||||
Pooling = false
|
||||
};
|
||||
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||
.UseNpgsql(databaseBuilder.ConnectionString, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
|
||||
.Options;
|
||||
using (var dbContext = new TikuDbContext(options))
|
||||
{
|
||||
dbContext.Database.Migrate();
|
||||
}
|
||||
|
||||
using (var adminConnection = new NpgsqlConnection(template.AdminConnectionString))
|
||||
{
|
||||
adminConnection.Open();
|
||||
using var preventConnections = adminConnection.CreateCommand();
|
||||
preventConnections.CommandText =
|
||||
$"ALTER DATABASE {PostgresTestDatabase.QuoteIdentifier(databaseName)} ALLOW_CONNECTIONS false";
|
||||
preventConnections.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => template.Dispose();
|
||||
return template;
|
||||
}
|
||||
catch
|
||||
{
|
||||
template.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
using var adminConnection = new NpgsqlConnection(AdminConnectionString);
|
||||
adminConnection.Open();
|
||||
using var dropDatabase = adminConnection.CreateCommand();
|
||||
dropDatabase.CommandText =
|
||||
$"DROP DATABASE IF EXISTS {PostgresTestDatabase.QuoteIdentifier(DatabaseName)}";
|
||||
dropDatabase.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
25
Tiku.IntegrationTests/MigrationExecutionTests.cs
Normal file
25
Tiku.IntegrationTests/MigrationExecutionTests.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.IntegrationTests.Api;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class MigrationExecutionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Api_test_database_uses_postgresql_and_applies_every_migration()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync();
|
||||
var expectedMigrations = dbContext.Database.GetMigrations();
|
||||
|
||||
Assert.Equal("Npgsql.EntityFrameworkCore.PostgreSQL", dbContext.Database.ProviderName);
|
||||
Assert.Equal(expectedMigrations, appliedMigrations);
|
||||
Assert.NotEmpty(appliedMigrations);
|
||||
Assert.True(await dbContext.Database.CanConnectAsync());
|
||||
}
|
||||
}
|
||||
@@ -513,7 +513,6 @@ public sealed class PersistenceModelTests
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantSecret), nameof(TenantSecret.SecretPayload), "'{}'::jsonb")]
|
||||
[InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")]
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
|
||||
Reference in New Issue
Block a user