eCourt-Doc-Edit/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs

251 lines
14 KiB
C#
Raw Normal View History

2026-08-20 13:51:42 +07:00
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;
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
using Microsoft.Extensions.DependencyInjection;
2026-08-20 13:51:42 +07:00
using Testcontainers.Minio;
using Testcontainers.PostgreSql;
using WordVersioning.Api.Data;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>ชื่อ collection ที่ใช้ fixture เดียวกัน — ทุก test class ต้อง [Collection(Name)]
/// เพื่อให้รันตามลำดับ (ไม่ parallel) กับ DB/container ชุดเดียวกัน</summary>
[CollectionDefinition(Name)]
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture>
{
public const string Name = "integration";
}
/// <summary>Foundation ของ integration tests: รัน PostgreSQL + MinIO + Keycloak จริงผ่าน
/// Testcontainers (ตามแผน Phase 7) แล้วยิง API ผ่าน WebApplicationFactory ที่ชี้ config ไปที่ container ทั้งหมด
///
/// - Keycloak: import realm จาก <c>Fixtures/realm-word-versioning-test.json</c> (สำเนาของ 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</summary>
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();
// quay.io mirror — Docker Hub ทำ minio/minio เป็น private แล้ว (เดียวกับ docker-compose)
private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:latest")
2026-08-20 13:51:42 +07:00
.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<Program> _factory = null!;
private string _accessToken = string.Empty;
/// <summary>userId จาก claim `sub` ของ token ที่ fixture ถืออยู่ — ใช้เทียบกับ CreatedByUserId (AC-5)</summary>
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<Program>()
.WithWebHostBuilder(builder =>
{
// Production = ไม่อ่าน appsettings.Development.json (DevHttps/cert ฯลฯ) — test ใช้ TestServer ไม่เกี่ยวกับ port จริง
builder.UseEnvironment("Production");
builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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",
Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word) การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/ file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน) open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox + backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic คือ download-first - Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) + OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey} — ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query), [AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง - Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด) - Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต - backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS (1 round, minors 4 แก้ครบ) หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:39:23 +07:00
// open-link (v9.2): BaseUrl เป็น dummy — test เอา PathAndQuery ของ URL ไป GET ผ่าน host ของ factory
["OpenLinks:Secret"] = "test-open-link-secret",
["OpenLinks:BaseUrl"] = "https://test-host",
2026-08-20 13:51:42 +07:00
}));
});
// สร้าง 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());
}
/// <summary>HttpClient ที่แนบ Bearer token ของ test user ไว้แล้ว</summary>
public HttpClient CreateClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
return client;
}
/// <summary>HttpClient ไม่แนบ token — ใช้ตรวจว่าได้ 401 (AC-3)</summary>
public HttpClient CreateAnonymousClient() => _factory.CreateClient();
/// <summary>DbContext ตรงไปที่ test database — สำหรับ assert สถานะใน Postgres จริง</summary>
public AppDbContext CreateDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options);
Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16) แก้ตามผลใช้งานจริง 2 ประเด็น: 1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays) + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0) 2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft) - ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init, useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19 - Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00
/// <summary>บริการ storage จริงจาก host ของ API — ใช้ทดสอบกับ MinIO container ตรงๆ (เช่น lifecycle rule)</summary>
public WordVersioning.Api.Services.IObjectStorageService CreateStorage() =>
_factory.Services.GetRequiredService<WordVersioning.Api.Services.IObjectStorageService>();
/// <summary>config MinIO ที่ host ใช้จริง (ชื่อ bucket ฯลฯ)</summary>
public WordVersioning.Api.Services.MinioOptions MinioConfig =>
_factory.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<WordVersioning.Api.Services.MinioOptions>>().Value;
2026-08-20 13:51:42 +07:00
/// <summary>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 คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>
private static async Task<string> RequestUserTokenAsync(string keycloakBase)
{
using var http = new HttpClient { BaseAddress = new Uri(keycloakBase) };
using var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<JsonElement>(Json);
return payload.GetProperty("access_token").GetString()!;
}
/// <summary>อ่าน claim จาก JWT payload (ไม่ต้อง validate signature — ได้มาจาก Keycloak โดยตรง)</summary>
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;
}
/// <summary>poll URL จนตอบ 2xx — Keycloak readiness probe แบบไม่ผูกกับ wait strategy API ของ Testcontainers</summary>
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}");
}
/// <summary>สร้าง bucket ทั้งสองแบบ private — ทำสิ่งเดียวกับ init service (mc) ของ docker-compose
/// พร้อม retry เผื่อ MinIO เพิ่งขึ้นและยังไม่รับ request</summary>
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 เพิ่งขึ้น — รอแล้วลองใหม่
}
}
}
}
}