using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Amazon.Runtime; using Amazon.S3; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Testcontainers.Minio; using Testcontainers.PostgreSql; using WordVersioning.Api.Data; namespace WordVersioning.Api.IntegrationTests; /// ชื่อ collection ที่ใช้ fixture เดียวกัน — ทุก test class ต้อง [Collection(Name)] /// เพื่อให้รันตามลำดับ (ไม่ parallel) กับ DB/container ชุดเดียวกัน [CollectionDefinition(Name)] public sealed class IntegrationTestCollection : ICollectionFixture { public const string Name = "integration"; } /// Foundation ของ integration tests: รัน PostgreSQL + MinIO + Keycloak จริงผ่าน /// Testcontainers (ตามแผน Phase 7) แล้วยิง API ผ่าน WebApplicationFactory ที่ชี้ config ไปที่ container ทั้งหมด /// /// - Keycloak: import realm จาก Fixtures/realm-word-versioning-test.json (สำเนาของ dev realm /// ที่เปิด password grant ให้ client word-versioning-web + ยืดอายุ access token — เฉพาะตอน test) /// user ของ test = `testuser` ที่มาพร้อม realm (เหมือน dev) → ขอ token จริงผ่าน password grant /// → backend validate ผ่าน JWKS เต็มขั้นตอน · (ไม่ใช้ master admin API เพราะ master realm /// บังคับ HTTPS บน token endpoint แม้ใน container http) /// - PostgreSQL: run EF migrations จริง /// - MinIO: สร้าง bucket ทั้งสองให้เหมือน init service ของ docker-compose public sealed class IntegrationTestFixture : IAsyncLifetime { public const string MinioUser = "minioadmin"; public const string MinioPassword = "minioadmin123"; public const string TestUserName = "testuser"; public const string TestUserPassword = "Test1234!"; public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev .Build(); private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") .WithUsername(MinioUser) .WithPassword(MinioPassword) .Build(); private readonly IContainer _keycloak = new ContainerBuilder("quay.io/keycloak/keycloak:26.2") .WithCommand("start-dev", "--import-realm") .WithPortBinding(8080, assignRandomHostPort: true) // realm สำหรับ test — เหมือน dev realm แต่เปิด password grant (ทำให้ขอ user token ได้โดยไม่ต้องเดิน PKCE) // ⚠️ target ของ overload นี้คือ "directory" (FilePath, DirectoryPath) — ชี้ dir แล้วไฟล์เข้าไป // ในนามเดิม realm-word-versioning-test.json ซึ่ง Keycloak import ทุก *.json ใน dir นี้ .WithResourceMapping( new FileInfo(Path.Combine(AppContext.BaseDirectory, "Fixtures", "realm-word-versioning-test.json")), "/opt/keycloak/data/import") .Build(); private WebApplicationFactory _factory = null!; private string _accessToken = string.Empty; /// userId จาก claim `sub` ของ token ที่ fixture ถืออยู่ — ใช้เทียบกับ CreatedByUserId (AC-5) public string TestUserId { get; private set; } = string.Empty; public string AccessToken => _accessToken; public async Task InitializeAsync() { // ยก containers ขึ้นพร้อมกัน — Keycloak ช้าสุด (~20-40s) ให้ที่เหลือรันคู่ไปเลย await Task.WhenAll(_postgres.StartAsync(), _minio.StartAsync(), _keycloak.StartAsync()); var keycloakBase = $"http://{_keycloak.Hostname}:{_keycloak.GetMappedPublicPort(8080)}"; var minioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(9000)}"; // Keycloak ใช้เวลา boot ~20-40s — รอจน realm endpoint ตอบ 200 จริงก่อน // (port เปิดไม่พอ: docker-proxy รับ connection ก่อนแล้วตัดสาย → "response ended prematurely") try { await WaitUntilHttpOk($"{keycloakBase}/realms/word-versioning/.well-known/openid-configuration"); } catch (TimeoutException ex) { // แนบ log ของ container มาด้วย — ไม่งั้นเห็นแค่ timeout ไม่รู้ว่า realm import พังหรือ boot ช้า var (stdout, _) = await _keycloak.GetLogsAsync(); var lines = stdout.Split('\n'); var tail = string.Join('\n', lines.Length > 40 ? lines[^40..] : lines); throw new TimeoutException($"{ex.Message}\n--- Keycloak logs (last 40 lines) ---\n{tail}"); } // user มาพร้อม realm (testuser) — ขอ password-grant token แล้วอ่าน `sub` เก็บไว้เทียบกับ CreatedByUserId _accessToken = await RequestUserTokenAsync(keycloakBase); TestUserId = ReadJwtClaim(_accessToken, "sub")!; _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { // Production = ไม่อ่าน appsettings.Development.json (DevHttps/cert ฯลฯ) — test ใช้ TestServer ไม่เกี่ยวกับ port จริง builder.UseEnvironment("Production"); builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:Default"] = _postgres.GetConnectionString(), ["MinIO:Endpoint"] = minioEndpoint, ["MinIO:AccessKey"] = MinioUser, ["MinIO:SecretKey"] = MinioPassword, ["MinIO:UseSSL"] = "false", ["Authentication:Authority"] = $"{keycloakBase}/realms/word-versioning", ["Authentication:ValidAudiences:0"] = "word-addin-officejs", ["Authentication:ValidAudiences:1"] = "word-versioning-web", ["Cors:AllowedOrigins:0"] = "https://localhost:3000", })); }); // สร้าง schema จริงด้วย EF migrations (ไม่ใช่ EnsureCreated — ต้องผ่าน migration เหมือน production) await using (var db = CreateDb()) { await db.Database.MigrateAsync(); } await CreateBucketsAsync(minioEndpoint); } public async Task DisposeAsync() { // null-safe: ถ้า InitializeAsync พังกลางทาง Dispose ต้องไม่พังตาม (กลบ error ตัวจริง) if (_factory is not null) await _factory.DisposeAsync(); await Task.WhenAll( _postgres.DisposeAsync().AsTask(), _minio.DisposeAsync().AsTask(), _keycloak.DisposeAsync().AsTask()); } /// HttpClient ที่แนบ Bearer token ของ test user ไว้แล้ว public HttpClient CreateClient() { var client = _factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken); return client; } /// HttpClient ไม่แนบ token — ใช้ตรวจว่าได้ 401 (AC-3) public HttpClient CreateAnonymousClient() => _factory.CreateClient(); /// DbContext ตรงไปที่ test database — สำหรับ assert สถานะใน Postgres จริง public AppDbContext CreateDb() => new(new DbContextOptionsBuilder() .UseNpgsql(_postgres.GetConnectionString()) .Options); /// user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น) /// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate /// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ) private static async Task RequestUserTokenAsync(string keycloakBase) { using var http = new HttpClient { BaseAddress = new Uri(keycloakBase) }; using var form = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["client_id"] = "word-versioning-web", ["username"] = TestUserName, ["password"] = TestUserPassword, }); using var response = await http.PostAsync("/realms/word-versioning/protocol/openid-connect/token", form); response.EnsureSuccessStatusCode(); var payload = await response.Content.ReadFromJsonAsync(Json); return payload.GetProperty("access_token").GetString()!; } /// อ่าน claim จาก JWT payload (ไม่ต้อง validate signature — ได้มาจาก Keycloak โดยตรง) private static string? ReadJwtClaim(string jwt, string claim) { var payload = jwt.Split('.')[1]; payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); return doc.RootElement.TryGetProperty(claim, out var value) ? value.GetString() : null; } /// poll URL จนตอบ 2xx — Keycloak readiness probe แบบไม่ผูกกับ wait strategy API ของ Testcontainers private static async Task WaitUntilHttpOk(string url, TimeSpan? timeout = null) { timeout ??= TimeSpan.FromMinutes(2); using var http = new HttpClient(); var deadline = DateTime.UtcNow + timeout.Value; while (DateTime.UtcNow < deadline) { try { using var response = await http.GetAsync(url); if (response.IsSuccessStatusCode) return; } catch (HttpRequestException) { // ยังไม่พร้อม — พยายามต่อ } await Task.Delay(1000); } throw new TimeoutException($"endpoint ไม่พร้อมภายใน {timeout}: {url}"); } /// สร้าง bucket ทั้งสองแบบ private — ทำสิ่งเดียวกับ init service (mc) ของ docker-compose /// พร้อม retry เผื่อ MinIO เพิ่งขึ้นและยังไม่รับ request private async Task CreateBucketsAsync(string minioEndpoint) { var config = new AmazonS3Config { ServiceURL = $"http://{minioEndpoint}", ForcePathStyle = true }; using var s3 = new AmazonS3Client( new BasicAWSCredentials(MinioUser, MinioPassword), config); foreach (var bucket in new[] { "docx-versions", "docx-templates" }) { for (var attempt = 1; ; attempt++) { try { // AWSSDK v4 ไม่มี DoesS3BucketExist — ยิงเลยแล้วถือว่าจบ (bucket ซ้ำ = ถือว่ามีอยู่แล้ว) try { await s3.PutBucketAsync(bucket); } catch (AmazonS3Exception ex) when (ex.ErrorCode == "BucketAlreadyOwnedByYou") { } break; } catch (AmazonS3Exception) when (attempt < 10) { await Task.Delay(500); // MinIO เพิ่งขึ้น — รอแล้วลองใหม่ } } } } }