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; 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(); 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(); 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(); 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() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); await SeedTenantAsync(factory, tenantId); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/tenant/resolve?tenantCode=master"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(tenantId.ToString(), document.RootElement.GetProperty("tenant").GetProperty("id").GetString()); Assert.Equal("升本刷题通", document.RootElement.GetProperty("branding").GetProperty("brandName").GetString()); Assert.True(document.RootElement.GetProperty("features").GetProperty("enableVocabulary").GetBoolean()); Assert.Equal("http://127.0.0.1:5173", document.RootElement.GetProperty("publicConfig").GetProperty("appUrl").GetString()); } [Fact] public async Task Resolve_returns_public_tenant_configuration_by_host() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); await SeedTenantAsync(factory, tenantId); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/tenant/resolve?host=student.example.test"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("student.example.test", document.RootElement.GetProperty("tenant").GetProperty("host").GetString()); } [Fact] public async Task Resolve_prefers_published_theme_config_over_branding_theme() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); await SeedTenantAsync(factory, tenantId); await factory.SeedAsync(new TenantThemeConfig { TenantId = tenantId, Status = TenantThemeConfigStatus.Published, ActiveTheme = JsonSerializer.SerializeToElement(new { color = "red" }), ActivePublicAssets = JsonSerializer.SerializeToElement(new { hero = "/hero.png" }) }); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/tenant/current-public?tenantCode=master"); var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("red", document.RootElement.GetProperty("branding").GetProperty("theme").GetProperty("color").GetString()); Assert.Equal("/hero.png", document.RootElement.GetProperty("branding").GetProperty("publicAssets").GetProperty("hero").GetString()); } [Fact] public async Task Resolve_returns_not_found_for_unknown_tenant() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/tenant/resolve?tenantCode=missing"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } [Fact] public async Task Health_returns_ok() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/health"); var body = await response.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("ok", body.GetProperty("status").GetString()); } [Fact] public async Task Cors_preflight_allows_configured_development_origin() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Options, "/api/health"); request.Headers.Add("Origin", "http://localhost:5173"); request.Headers.Add("Access-Control-Request-Method", "GET"); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); Assert.Equal("http://localhost:5173", response.Headers.GetValues("Access-Control-Allow-Origin").Single()); } private static Task SeedTenantAsync(ApiTestFactory factory, Guid tenantId) { return factory.SeedAsync( new Tenant { Id = tenantId, Slug = "master", Name = "升本刷题通", Status = TenantStatus.Active, Mode = TenantMode.PlatformOwned }, new TenantDomain { TenantId = tenantId, Host = "student.example.test", DomainType = TenantDomainType.Custom, Status = TenantDomainStatus.Active, IsPrimary = true }, new TenantBranding { TenantId = tenantId, BrandName = "升本刷题通", ShortName = "刷题通", Slogan = "多租户专升本题库 SaaS", LogoUrl = "https://example.test/logo.png", FaviconUrl = "https://example.test/favicon.ico", ServiceWechat = "service-wechat", ServiceAccountName = "服务号", Theme = JsonSerializer.SerializeToElement(new { color = "blue" }), PublicAssets = JsonDefaults.Object() }, new TenantSettings { TenantId = tenantId, FeatureFlags = JsonSerializer.SerializeToElement(new { enableVocabulary = true }), AdminFeatureFlags = JsonSerializer.SerializeToElement(new { enableTenantManagement = true }), PublicConfig = JsonSerializer.SerializeToElement(new { appUrl = "http://127.0.0.1:5173" }) }); } }