Phase 7-8

This commit is contained in:
Suphonchai Phoonsawat 2026-08-20 13:51:42 +07:00
parent a196b8f9a8
commit 0148f48204
25 changed files with 2473 additions and 47 deletions

View file

@ -0,0 +1,51 @@
using System.Net;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-8 ฝั่ง backend + AC-3 — ทุก endpoint ต้อง [Authorize]: ไม่แนบ token → 401 ทั้งหมด</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class AuthTests(IntegrationTestFixture fixture)
{
[Theory]
[InlineData("GET", "api/templates")]
[InlineData("POST", "api/templates")]
[InlineData("GET", "api/templates/{0}/documents")]
[InlineData("POST", "api/documents/generate")]
[InlineData("GET", "api/documents/{0}/versions")]
[InlineData("POST", "api/documents/{0}/versions")]
[InlineData("GET", "api/documents/{0}/versions/{1}/download")]
[InlineData("GET", "api/documents/{0}")]
public async Task WithoutToken_AllEndpoints_Return401(string method, string routeTemplate)
{
using var client = fixture.CreateAnonymousClient();
var route = routeTemplate
.Replace("{0}", Guid.NewGuid().ToString())
.Replace("{1}", Guid.NewGuid().ToString());
using var request = new HttpRequestMessage(new HttpMethod(method), route);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task WithGarbageToken_Returns401()
{
using var client = fixture.CreateAnonymousClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "not-a-real-jwt");
using var response = await client.GetAsync("api/templates");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task WithValidToken_Returns200()
{
using var client = fixture.CreateClient();
using var response = await client.GetAsync("api/templates");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View file

@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-11 — field sync ตอน Save & Version: latest values อัปเดต + snapshot ต่อ version
/// + missing-field policy (AC-13: ค่าเปลี่ยนตาม, AC-14: field หาย → ค่าเดิม + mark missing ไม่ fail)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class FieldSyncTests(IntegrationTestFixture fixture)
{
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
private async Task<(HttpClient Client, Guid DocumentId, Guid V1VersionId)> CreateTrackedDocumentAsync()
{
var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync");
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
return (client, doc.DocumentId, doc.VersionId);
}
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
[Fact]
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
{
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
var v2Fields = new Dictionary<string, string>
{
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
["salary"] = "99000",
["start_date"] = "2026-08-01", // ไม่เปลี่ยน
};
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
JsonSerializer.Serialize(v2Fields)));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
var v2 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
// จึงชี้ SourceVersionId = v2 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v1 (ดู test AC-14)
await using var db = fixture.CreateDb();
var values = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey);
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value);
Assert.Equal(v2!.VersionId, values["employee_name"].SourceVersionId);
Assert.Equal("99000", values["salary"].Value);
Assert.Equal(v2.VersionId, values["salary"].SourceVersionId);
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v2
Assert.Equal(v2.VersionId, values["position"].SourceVersionId);
Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId);
// snapshot ต่อ version ผ่าน history API: v2 = ค่าใหม่, v1 = ค่าเดิม (missing=false ทั้งคู่)
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))!
.OrderBy(i => i.VersionNumber)
.ToList();
var v1Snapshot = Snapshot(items[0].Fields!.Value);
var v2Snapshot = Snapshot(items[1].Fields!.Value);
Assert.Equal("สมหญิง รักงาน", v1Snapshot["employee_name"].Value);
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v2Snapshot["employee_name"].Value);
Assert.Equal("99000", v2Snapshot["salary"].Value);
Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing));
Assert.All(v2Snapshot.Values, e => Assert.False(e.Missing));
}
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
/// ใน snapshot; field ที่ส่งเกินนิยามถูก ignore</summary>
[Fact]
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
{
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx("ลบ control ไป"), "doc.docx", Guid.NewGuid().ToString(),
"""{"employee_name":"สมหญิง ยังอยู่","อะไรที่ไม่มีในนิยาม":"ส่งเกินมา"}"""));
// policy FR-11: ไม่ fail request
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
// latest values ของ field ที่หาย: คงค่า v1 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v1)
await using var db = fixture.CreateDb();
var values = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey);
Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value);
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value);
Assert.Equal(v1VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
Assert.Equal("75000", values["salary"].Value);
Assert.Equal("2026-08-01", values["start_date"].Value);
Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ
// snapshot v2: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 2).Fields!.Value);
Assert.False(snapshot["employee_name"].Missing);
Assert.True(snapshot["position"].Missing);
Assert.Equal("วิศวกรซอฟต์แวร์", snapshot["position"].Value); // ค่าเดิม
Assert.True(snapshot["salary"].Missing);
Assert.True(snapshot["start_date"].Missing);
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
}
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1)</summary>
[Fact]
public async Task DetailReflectsLatestSyncedValues()
{
var (client, documentId, _) = await CreateTrackedDocumentAsync();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
"""{"employee_name":"ชื่อใหม่สุด","position":"ตำแหน่งใหม่สุด","salary":"1","start_date":"2026-08-20"}"""));
upload.EnsureSuccessStatusCode();
using var detail = await client.GetAsync($"api/documents/{documentId}");
var body = await detail.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
var byKey = body!.Fields.ToDictionary(f => f.Key);
Assert.Equal("ชื่อใหม่สุด", byKey["employee_name"].Value);
Assert.Equal("ตำแหน่งใหม่สุด", byKey["position"].Value);
Assert.Equal("1", byKey["salary"].Value);
}
/// <summary>แปลง snapshot JSON (structured {key:{value,missing}}) เป็น dictionary ใช้ใน test</summary>
private static Dictionary<string, (string Value, bool Missing)> Snapshot(JsonElement fields)
{
var result = new Dictionary<string, (string, bool)>();
foreach (var prop in fields.EnumerateObject())
result[prop.Name] = (
prop.Value.GetProperty("value").GetString() ?? "",
prop.Value.GetProperty("missing").GetBoolean());
return result;
}
}

View file

@ -0,0 +1,184 @@
{
"realm": "word-versioning",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"resetPasswordAllowed": false,
"bruteForceProtected": false,
"clients": [
{
"clientId": "word-addin-officejs",
"name": "Word Versioning Add-in (Office.js task pane)",
"description": "Public client สำหรับ task pane — OIDC Authorization Code + PKCE S256 ผ่าน Office Dialog API (redirect กลับ auth-callback.html)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true,
"surrogateAuthRequired": false,
"redirectUris": [
"https://localhost:3000/auth-callback.html"
],
"webOrigins": [
"https://localhost:3000"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"protocolMappers": [
{
"name": "audience-self",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "word-addin-officejs",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "word-versioning-web",
"name": "Word Versioning Web Portal (Nuxt)",
"description": "Public client สำหรับเว็บ portal — OIDC browser redirect ปกติ (top-level page) + PKCE S256",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true,
"surrogateAuthRequired": false,
"redirectUris": [
"http://localhost:5173/*"
],
"webOrigins": [
"http://localhost:5173"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"protocolMappers": [
{
"name": "audience-self",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "word-versioning-web",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "word-addin-vsto",
"name": "Word Versioning Add-in (VSTO desktop)",
"description": "Pre-register ไว้ตาม spec (spec-vsto.md ยังไม่มีงาน implement) — public + PKCE, redirect เป็น loopback ตาม RFC 8252",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true,
"surrogateAuthRequired": false,
"redirectUris": [
"http://localhost/*",
"http://127.0.0.1/*"
],
"webOrigins": [],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"protocolMappers": [
{
"name": "audience-self",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "word-addin-vsto",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}
],
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "testuser@word-versioning.local",
"emailVerified": true,
"firstName": "Test",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "Test1234!",
"temporary": false
}
]
}
],
"accessTokenLifespan": 3600
}

View file

@ -0,0 +1,219 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using WordVersioning.Api.Services;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class GenerateTests(IntegrationTestFixture fixture)
{
[Fact]
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV1()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
Assert.Equal(1, doc.VersionNumber);
Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl);
using var download = await client.GetAsync(doc.DownloadUrl);
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
var fileBytes = await download.Content.ReadAsByteArrayAsync();
// (1) ค่าทุก field ถูกแทนที่ใน SDT
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(fileBytes, "position"));
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
// (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
// และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
Assert.True(runWithText.Bold);
Assert.False(runWithText.Italic);
// (3) custom properties ถูก stamp ลงไฟล์ — add-in ใช้ค่านี้ตรวจ tracked document (FR-1/FR-10)
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
// (4) สถานะใน Postgres: Document ผูก template + v1 + field values ครบ
await using var db = fixture.CreateDb();
var document = await db.Documents.AsNoTracking()
.Include(d => d.Versions)
.SingleAsync(d => d.Id == doc.DocumentId);
Assert.Equal(templateId, document.TemplateId);
Assert.Equal(AddInMetadata.AddInSignatureGuid, document.AddInSignature);
Assert.EndsWith(".docx", document.FileName);
var version = Assert.Single(document.Versions);
Assert.Equal(1, version.VersionNumber);
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
.Where(v => v.DocumentId == doc.DocumentId)
.ToDictionaryAsync(v => v.FieldKey);
Assert.Equal(4, fieldValues.Count);
Assert.Equal("สมหญิง รักงาน", fieldValues["employee_name"].Value);
Assert.Equal("75000", fieldValues["salary"].Value);
Assert.All(fieldValues.Values, v => Assert.Equal(doc.VersionId, v.SourceVersionId));
Assert.All(fieldValues.Values, v => Assert.Equal(fixture.TestUserId, v.UpdatedByUserId));
}
[Fact]
public async Task Generate_MissingRequiredField_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "required ขาด");
var values = new Dictionary<string, string>(TestFlow.DefaultValues);
values.Remove("employee_name"); // required field หาย
using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadAsStringAsync();
Assert.Contains("employee_name", error);
}
[Theory]
[InlineData("salary", "ไม่ใช่เลข")]
[InlineData("start_date", "ไม่ใช่วันที่")]
public async Task Generate_WrongFieldType_Returns400(string key, string badValue)
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"type ผิด {key}");
var values = new Dictionary<string, string>(TestFlow.DefaultValues) { [key] = badValue };
using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Generate_IgnoresUndefinedValues()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "ค่าเกินนิยาม");
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
{
["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore
};
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
Assert.Equal(1, doc.VersionNumber);
await using var db = fixture.CreateDb();
Assert.False(await db.DocumentFieldValues.AsNoTracking()
.AnyAsync(v => v.DocumentId == doc.DocumentId && v.FieldKey == "field_ที่ไม่มีใน_template"));
}
[Fact]
public async Task Generate_UnknownTemplate_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.PostAsJsonAsync(
"api/documents/generate",
new { templateId = Guid.NewGuid(), values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>FR-12 — detail ของเอกสาร: merge นิยาม × ค่าล่าสุด แบบรองรับ orphan ทั้งสองทาง
/// (template replace ไฟล์แล้ว: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)</summary>
[Fact]
public async Task GetDetail_MergesDefsAndValues_BothOrphanWays()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "orphan merge");
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
// replace ไฟล์ template: เหลือ employee_name + เพิ่ม note → position/salary/start_date กลายเป็นค่า orphan
using var replace = await client.PutAsync(
$"api/templates/{templateId}/file",
new MultipartFormDataContent
{
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
"file", "replaced.docx" },
});
replace.EnsureSuccessStatusCode();
using var detailResponse = await client.GetAsync($"api/documents/{doc.DocumentId}");
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
var detail = await detailResponse.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
Assert.Equal(templateId, detail!.TemplateId);
var byKey = detail.Fields.ToDictionary(f => f.Key);
// def ใหม่ (note) ยังไม่มีค่า — แสดงเป็นค่าว่าง + ไม่มี source version
Assert.Equal("", byKey["note"].Value);
Assert.Null(byKey["note"].SourceVersionId);
Assert.Equal("note", byKey["note"].Label);
// def เดิมที่ยังอยู่ (employee_name) — ค่า v1 ยังอยู่ครบ
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
// ค่าเก่าที่ def หายไป (position/salary/start_date) — ต่อท้ายโดยไม่มีนิยาม
Assert.Null(byKey["position"].Label);
Assert.Equal("วิศวกรซอฟต์แวร์", byKey["position"].Value);
Assert.Equal("75000", byKey["salary"].Value);
Assert.Equal("2026-08-01", byKey["start_date"].Value);
}
/// <summary>FR-12 — รายการเอกสารต่อ template: versionCount/Latest ถูกต้อง เรียงกิจกรรมล่าสุดก่อน</summary>
[Fact]
public async Task ListDocuments_ShowsVersionCounts_LatestFirst()
{
using var client = fixture.CreateClient();
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "รายการเอกสาร");
var firstDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
var secondDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
// เพิ่ม v2 ให้เอกสารแรก → กิจกรรมล่าสุดเป็นของเอกสารแรก
using var upload = await client.PostAsync(
$"api/documents/{firstDoc.DocumentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx("แก้ไข"), "doc.docx", Guid.NewGuid().ToString(),
$$"""{"employee_name":"สมหญิง แก้ไขแล้ว","position":"วิศวกรซอฟต์แวร์","salary":"80000","start_date":"2026-08-01"}"""));
upload.EnsureSuccessStatusCode();
using var listResponse = await client.GetAsync($"api/templates/{templateId}/documents");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var items = await listResponse.Content.ReadFromJsonAsync<List<TemplateDocItem>>(IntegrationTestFixture.Json);
Assert.Equal(2, items!.Count);
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
Assert.Equal(2, items[0].VersionCount);
Assert.Equal(2, items[0].LatestVersionNumber);
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
Assert.Equal(1, items[1].VersionCount);
}
/// <summary>FR-12 — detail ของเอกสารที่ไม่ผูก template (track เองจาก add-in): Fields ว่าง templateId null</summary>
[Fact]
public async Task GetDetail_OfStandaloneDocument_HasEmptyFields()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "เอกสารอิสระ.docx", Guid.NewGuid().ToString()));
upload.EnsureSuccessStatusCode();
using var response = await client.GetAsync($"api/documents/{documentId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var detail = await response.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
Assert.Null(detail!.TemplateId);
Assert.Null(detail.TemplateName);
Assert.Empty(detail.Fields);
}
}

View file

@ -0,0 +1,237 @@
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;
/// <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();
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<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",
}));
});
// สร้าง 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);
/// <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 เพิ่งขึ้น — รอแล้วลองใหม่
}
}
}
}
}

View file

@ -0,0 +1,236 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-9 — template CRUD + auto-detect SDT tags ตอน upload/replace ไฟล์ (AC-11 ส่วน backend)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class TemplateTests(IntegrationTestFixture fixture)
{
/// <summary>สร้าง template จาก BuildTemplateDocx() — คืน (id, fields ที่ detect ได้) — ใช้ร่วมกับ test อื่น</summary>
public static async Task<(Guid TemplateId, List<TemplateFieldItem> Fields)> CreateDefaultTemplateAsync(
HttpClient client, string name)
{
using var response = await client.PostAsync(
"api/templates", ApiExtensions.TemplateForm(TestDocx.BuildTemplateDocx(), name));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json);
return (result!.Id, result.Fields);
}
[Fact]
public async Task Create_DetectsAllSdtTags_WithDefaults()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"สัญญา {Guid.NewGuid():N}");
// 5 tags ในไฟล์ (employee_name ซ้ำ 2 จุด → dedup เหลือ 4) ตามลำดับที่ปรากฏ
Assert.Equal(
["employee_name", "position", "salary", "start_date"],
fields.Select(f => f.Key).ToList());
// defaults: label = tag, type = text, required = false (FR-9)
Assert.All(fields, f =>
{
Assert.Equal(f.Key, f.Label);
Assert.Equal("text", f.Type);
Assert.False(f.IsRequired);
});
// GET detail คืนนิยามชุดเดิม
using var detail = await client.GetAsync($"api/templates/{templateId}");
var detailBody = await detail.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
Assert.Equal(4, detailBody!.Fields.Count);
}
[Fact]
public async Task Create_RejectsNonDocxFile()
{
using var client = fixture.CreateClient();
using var form = new MultipartFormDataContent();
form.Add(new StringContent("ไม่ใช่ template"), "name");
var file = new ByteArrayContent("plain"u8.ToArray());
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
form.Add(file, "file", "template.txt");
using var response = await client.PostAsync("api/templates", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Create_RejectsMissingName()
{
using var client = fixture.CreateClient();
using var form = new MultipartFormDataContent();
var file = new ByteArrayContent(TestDocx.BuildPlainDocx());
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType);
form.Add(file, "file", "template.docx"); // ไม่ส่ง name
using var response = await client.PostAsync("api/templates", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Update_SavesFieldDefinitions_AndMetadata()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"แก้นิยาม {Guid.NewGuid():N}");
var updated = new
{
description = "สัญญาจ้างฉบับมาตรฐาน",
fields = fields.Select((f, i) => new
{
key = f.Key,
label = $"ป้ายกำกับ {f.Key}",
type = f.Key == "salary" ? "number" : f.Key == "start_date" ? "date" : "text",
isRequired = f.Key == "employee_name",
sortOrder = fields.Count - i, // กลับลำดับ
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
Assert.Equal("สัญญาจ้างฉบับมาตรฐาน", body!.Description);
Assert.Equal("ป้ายกำกับ employee_name", body.Fields[3].Label); // กลับลำดับแล้ว employee_name ท้ายสุด
Assert.Equal("number", body.Fields.First(f => f.Key == "salary").Type);
Assert.Equal("date", body.Fields.First(f => f.Key == "start_date").Type);
Assert.True(body.Fields.First(f => f.Key == "employee_name").IsRequired);
}
[Fact]
public async Task Update_RejectsUnknownFieldKey()
{
using var client = fixture.CreateClient();
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"key นอกไฟล์ {Guid.NewGuid():N}");
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", new
{
fields = new[] { new { key = "not_in_file", label = "ไม่มีในไฟล์", type = "text", isRequired = false, sortOrder = 0 } },
}, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode); // เพิ่ม/ลบ key ต้องผ่าน replace ไฟล์เท่านั้น
}
[Fact]
public async Task Update_RejectsInvalidFieldType()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"type ผิด {Guid.NewGuid():N}");
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", new
{
fields = fields.Select(f => new { key = f.Key, label = f.Key, type = "datetime", isRequired = false, sortOrder = f.SortOrder }),
}, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
}
[Fact]
public async Task ReplaceFile_DetectsAddedAndRemovedTags()
{
using var client = fixture.CreateClient();
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"replace {Guid.NewGuid():N}");
using var put = await client.PutAsync(
$"api/templates/{templateId}/file",
new MultipartFormDataContent
{
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
"file", "replaced.docx" },
});
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<ReplaceFileResult>(IntegrationTestFixture.Json);
// เดิม: employee_name, position, salary, start_date → ใหม่: employee_name, note
Assert.Equal(["note"], body!.AddedTags);
Assert.Equal(
new[] { "position", "salary", "start_date" }.Order(),
body.RemovedTags.Order());
Assert.Equal(["employee_name", "note"], body.Fields.Select(f => f.Key).ToList());
}
[Fact]
public async Task Deactivate_HidesFromDefaultList_ButVisibleWithIncludeInactive()
{
using var client = fixture.CreateClient();
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"deactivate {Guid.NewGuid():N}");
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
using var activeList = await client.GetAsync("api/templates");
var active = await activeList.Content.ReadFromJsonAsync<List<TemplateListItemResult>>(IntegrationTestFixture.Json);
Assert.DoesNotContain(active!, t => t.Id == templateId);
using var allList = await client.GetAsync("api/templates?includeInactive=true");
var all = await allList.Content.ReadFromJsonAsync<List<TemplateListItemResult>>(IntegrationTestFixture.Json);
Assert.Contains(all!, t => t.Id == templateId && !t.IsActive);
}
[Fact]
public async Task Deactivate_ThenGenerate_Returns400_ButDocumentsOfTemplateStillAccessible()
{
using var client = fixture.CreateClient();
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"deactivate+generate {Guid.NewGuid():N}");
// สร้างเอกสาร 1 ฉบับก่อน deactivate
var values = new Dictionary<string, string>
{
["employee_name"] = "สมชาย ใจดี",
["position"] = "โปรแกรมเมอร์",
["salary"] = "50000",
["start_date"] = "2026-08-20",
};
using var generate = await client.PostAsJsonAsync("api/documents/generate",
new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.Created, generate.StatusCode);
var doc = await generate.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json);
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
// สร้างเอกสารใหม่ไม่ได้ (FR-9 policy)
using var generateAgain = await client.PostAsJsonAsync("api/documents/generate",
new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, generateAgain.StatusCode);
// แต่เอกสารเก่ายังเข้าถึงได้ครบ: list + detail + history + download (FR-9 + FR-12)
using var list = await client.GetAsync($"api/templates/{templateId}/documents");
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
var documents = await list.Content.ReadFromJsonAsync<List<TemplateDocItem>>(IntegrationTestFixture.Json);
var listItem = documents!.Single();
Assert.Equal(doc!.DocumentId, listItem.DocumentId);
Assert.Equal(1, listItem.VersionCount);
using var detail = await client.GetAsync($"api/documents/{doc!.DocumentId}");
Assert.Equal(HttpStatusCode.OK, detail.StatusCode);
Assert.True((await detail.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json))!.TemplateIsActive is false);
using var history = await client.GetAsync($"api/documents/{doc.DocumentId}/versions");
Assert.Equal(HttpStatusCode.OK, history.StatusCode);
using var download = await client.GetAsync($"api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download");
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
}
[Fact]
public async Task Get_UnknownTemplate_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.GetAsync($"api/templates/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task ListDocuments_UnknownTemplate_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.GetAsync($"api/templates/{Guid.NewGuid()}/documents");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,146 @@
using System.Text;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>สร้าง/อ่านไฟล์ .docx สำหรับ test — ไม่ต้องพึ่ง binary file ใน repo
/// สร้างได้ทั้ง template ที่มี content controls (SDT) หลายรูปแบบและไฟล์เปล่าไม่มี SDT</summary>
public static class TestDocx
{
public const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/// <summary>สร้าง template มาตรฐาน: SDT แบบ run เดียว / multi-run คนละ formatting / block-level
/// + tag ซ้ำอีกจุด (detect ต้อง dedup) — ครอบคลุมรูปแบบเดียวกับที่ใช้ตรวจ Phase 3 ตอน dev
///
/// ⚠️ run-level SDT (SdtRun) ต้องอยู่ "ใน" paragraph เท่านั้น (ตาม OOXML schema เหมือนที่ Word author ให้)
/// — วางเป็นลูกตรงของ Body แล้วตอน reload จะกลายเป็น SdtBlock + OpenXmlUnknownElement ทั้งชุด
/// ทำให้ generate เข้า branch ผิด (append แทนที่จะ replace)</summary>
public static byte[] BuildTemplateDocx()
{
var buffer = new MemoryStream();
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body(
new Paragraph(new Run(new Text("สัญญาจ้างงานฉบับทดสอบ"))),
// SDT run-level เดี่ยว — รูปแบบที่พบบ่อยสุด
new Paragraph(SdtRun("employee_name", new Run(
new RunProperties(new Bold()),
new Text("[ชื่อพนักงาน]")))),
// SDT multi-run คนละ formatting — generate ต้องเขียนค่าที่ run แรก (รักษา bold) และลบ text ของ run หลัง
new Paragraph(SdtRun("position",
new Run(
new RunProperties(new Bold()),
new Text("[ตำแหน่ง")),
new Run(
new RunProperties(new Italic()),
new Text("หน้าที่]")))),
// SDT block-level (ทั้งย่อหน้า) — ตัวนี้เท่านั้นที่เป็นลูกตรงของ Body ได้
new SdtBlock(
new SdtProperties(new Tag { Val = "salary" }),
new SdtContentBlock(new Paragraph(new Run(new Text("[เงินเดือน]"))))),
new Paragraph(SdtRun("start_date", new Run(new Text("[วันเริ่มงาน]")))),
// tag ซ้ำ (อีกจุดของเอกสาร) — detect ต้องได้ครั้งเดียว
new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อพนักงานอีกครั้ง]")))),
new Paragraph(new Run(new Text("จบเอกสาร")))));
}
return buffer.ToArray();
}
/// <summary>template ตัวแปร: มี employee_name + position เท่านั้น — ใช้ตอน replace ไฟล์ (tag เพิ่ม/หาย)</summary>
public static byte[] BuildReplacementTemplateDocx()
{
var buffer = new MemoryStream();
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body(
new Paragraph(new Run(new Text("สัญญาฉบับแก้ไข"))),
new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อ]")))),
new Paragraph(SdtRun("note", new Run(new Text("[หมายเหตุ]"))))));
}
return buffer.ToArray();
}
/// <summary>ไฟล์ .docx ไม่มี content control เลย — จำลองเอกสารธรรมดาที่ track เองผ่าน add-in</summary>
public static byte[] BuildPlainDocx(string text = "เอกสารทั่วไปไม่มี content control")
{
var buffer = new MemoryStream();
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(new Body(new Paragraph(new Run(new Text(text)))));
}
return buffer.ToArray();
}
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
public static string ReadSdtText(byte[] docx, string tag)
{
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
var main = doc.MainDocumentPart!;
foreach (var root in new OpenXmlPartRootElement?[] { main.Document }
.Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header))
.Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer)))
{
if (root is null) continue;
foreach (var sdtTag in root.Descendants<Tag>())
{
if (sdtTag.Val?.Value != tag) continue;
var sdt = sdtTag.Parent?.Parent;
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
if (content is null) return "";
var sb = new StringBuilder();
foreach (var text in content.Descendants<Text>())
sb.Append(text.Text);
return sb.ToString();
}
}
return "";
}
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
/// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้</summary>
public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag)
{
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
var root = doc.MainDocumentPart!.Document!;
foreach (var sdtTag in root.Descendants<Tag>())
{
if (sdtTag.Val?.Value != tag) continue;
var sdt = sdtTag.Parent?.Parent;
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
return content?.Descendants<Run>()
.Select(run => (
string.Concat(run.Descendants<Text>().Select(t => t.Text)),
run.RunProperties?.Bold is not null,
run.RunProperties?.Italic is not null))
.ToList() ?? [];
}
return [];
}
/// <summary>อ่าน custom property (VTLPWSTR) — null เมื่อไม่มี property นั้น
/// ใช้ตรวจ SavedByAddIn / DocumentTrackingId ที่ backend stamp ลงไฟล์ (FR-10)</summary>
public static string? ReadCustomProperty(byte[] docx, string name)
{
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
var props = doc.CustomFilePropertiesPart?.Properties;
return props?.Elements<DocumentFormat.OpenXml.CustomProperties.CustomDocumentProperty>()
.FirstOrDefault(p => p.Name?.Value == name)
?.VTLPWSTR?.Text;
}
private static SdtRun SdtRun(string tag, params Run[] runs) => new(
new SdtProperties(new Tag { Val = tag }),
new SdtContentRun(runs));
}

View file

@ -0,0 +1,64 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary>
public record UploadVersionResult(Guid VersionId, int VersionNumber);
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder);
public record CreateTemplateResult(Guid Id, List<TemplateFieldItem> Fields);
public record TemplateDetailResult(Guid Id, string Name, string? Description, bool IsActive, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, List<TemplateFieldItem> Fields);
public record ReplaceFileResult(Guid Id, List<TemplateFieldItem> Fields, List<string> AddedTags, List<string> RemovedTags);
public record TemplateListItemResult(Guid Id, string Name, string? Description, bool IsActive, int FieldCount, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public record TemplateDocItem(Guid DocumentId, string FileName, DateTimeOffset CreatedAt, int VersionCount, int LatestVersionNumber, DateTimeOffset? LatestVersionAt);
public record DocFieldItem(string Key, string? Label, string? Type, bool? IsRequired, string Value, Guid? SourceVersionId, string? UpdatedByUserId, DateTimeOffset? UpdatedAt);
public record DocDetail(Guid Id, string FileName, DateTimeOffset CreatedAt, Guid? TemplateId, string? TemplateName, bool TemplateIsActive, List<DocFieldItem> Fields);
public static class ApiExtensions
{
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option)</summary>
public static MultipartFormDataContent VersionForm(
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null)
{
var form = new MultipartFormDataContent();
var file = new ByteArrayContent(docx);
file.Headers.ContentType = new MediaTypeHeaderValue(TestDocx.DocxContentType);
form.Add(file, "file", fileName);
form.Add(new StringContent(clientVersionId), "clientVersionId");
if (fieldsJson is not null)
form.Add(new StringContent(fieldsJson), "fields");
return form;
}
/// <summary>multipart form ของ POST templates / PUT file: name + description (option) + file</summary>
public static MultipartFormDataContent TemplateForm(
byte[] docx, string name, string? description = null, string fileName = "template.docx")
{
var form = new MultipartFormDataContent();
form.Add(new StringContent(name), "name");
if (description is not null)
form.Add(new StringContent(description), "description");
var file = new ByteArrayContent(docx);
file.Headers.ContentType = new MediaTypeHeaderValue(TestDocx.DocxContentType);
form.Add(file, "file", fileName);
return form;
}
public static async Task<T?> ReadJsonAsync<T>(this HttpResponseMessage response) =>
response.Content.Headers.ContentType is null
? default
: await response.Content.ReadFromJsonAsync<T>(IntegrationTestFixture.Json);
}

View file

@ -0,0 +1,74 @@
using System.Net;
using System.Net.Http.Json;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>setup ที่ใช้ร่วมกันหลาย test class: สร้าง template + ตั้ง field definitions ครบชุด
/// (employee_name = required text, position = text, salary = number, start_date = date)</summary>
public static class TestFlow
{
public static readonly Dictionary<string, string> DefaultValues = new()
{
["employee_name"] = "สมหญิง รักงาน",
["position"] = "วิศวกรซอฟต์แวร์",
["salary"] = "75000",
["start_date"] = "2026-08-01",
};
public static async Task<(Guid TemplateId, List<TemplateFieldItem> Fields)> CreateConfiguredTemplateAsync(
HttpClient client, string baseName)
{
// create (detect defaults)
using var create = await client.PostAsync(
"api/templates", ApiExtensions.TemplateForm(TestDocx.BuildTemplateDocx(), $"{baseName} {Guid.NewGuid():N}"));
create.EnsureSuccessStatusCode();
var created = await create.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json);
// ตั้ง label/type/required ให้ครบทุกรูปแบบ (มี required, number, date)
using var put = await client.PutAsJsonAsync($"api/templates/{created!.Id}", new
{
fields = created.Fields.Select(f => new
{
key = f.Key,
label = LabelOf(f.Key),
type = TypeOf(f.Key),
isRequired = f.Key == "employee_name",
sortOrder = f.SortOrder,
}),
}, IntegrationTestFixture.Json);
put.EnsureSuccessStatusCode();
var detail = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
return (created.Id, detail!.Fields);
}
/// <summary>generate เอกสาร v1 จาก template ที่ตั้งค่าแล้ว — คืน response ของ generate</summary>
public static Task<GenerateResult> GenerateDocumentAsync(HttpClient client, Guid templateId) =>
GenerateDocumentAsync(client, templateId, DefaultValues);
/// <summary>generate พร้อมค่าที่กำหนดเอง (ทดสอบ validation หรือค่าเกินนิยาม)</summary>
public static async Task<GenerateResult> GenerateDocumentAsync(
HttpClient client, Guid templateId, Dictionary<string, string> values)
{
using var response = await client.PostAsJsonAsync(
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json))!;
}
public static string LabelOf(string key) => key switch
{
"employee_name" => "ชื่อพนักงาน",
"position" => "ตำแหน่ง",
"salary" => "เงินเดือน",
"start_date" => "วันเริ่มงาน",
_ => key,
};
public static string TypeOf(string key) => key switch
{
"salary" => "number",
"start_date" => "date",
_ => "text",
};
}

View file

@ -0,0 +1,171 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-5 (upload), FR-6 (history), FR-7 (download) — ผ่าน HTTP จริง + ตรวจสถานะใน Postgres ตรงๆ</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class UploadVersionTests(IntegrationTestFixture fixture)
{
[Fact]
public async Task FirstUpload_CreatesDocumentAndVersionRow_FromJwtClaims()
{
var documentId = Guid.NewGuid();
var clientVersionId = Guid.NewGuid();
var bytes = TestDocx.BuildPlainDocx("version 1 content");
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(bytes, "เอกสารทดสอบ.docx", clientVersionId.ToString()));
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
Assert.Equal(1, result!.VersionNumber);
Assert.NotEqual(Guid.Empty, result.VersionId);
// ตรวจ DB จริง: Document auto-create + ค่า tracking จาก JWT claims (AC-5)
await using var db = fixture.CreateDb();
var doc = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
Assert.Equal("เอกสารทดสอบ.docx", doc.FileName);
Assert.Null(doc.TemplateId); // track เองจาก add-in — ไม่ผูก template
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
Assert.Equal(1, version.VersionNumber);
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // sub จาก token เท่านั้น
Assert.Equal(Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), version.ContentHash);
Assert.Equal($"{documentId}/v0001_{clientVersionId}.docx", version.ObjectKey);
}
[Fact]
public async Task SecondUpload_IncrementsVersionNumber_AndHistorySortsNewestFirst()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
var v1 = TestDocx.BuildPlainDocx("v1");
using var first = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(v1, "doc.docx", Guid.NewGuid().ToString()));
var firstResult = await first.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
var v2 = TestDocx.BuildPlainDocx("v2 — แก้ไขแล้ว");
using var second = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(v2, "doc.docx", Guid.NewGuid().ToString()));
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
var secondResult = await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
Assert.Equal(2, secondResult!.VersionNumber);
// FR-6: เรียงใหม่ → เก่า
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
Assert.Equal(2, items!.Count);
Assert.Equal(secondResult.VersionId, items[0].VersionId);
Assert.Equal(2, items[0].VersionNumber);
Assert.Equal(firstResult!.VersionId, items[1].VersionId);
Assert.Equal(1, items[1].VersionNumber);
Assert.All(items, i => Assert.Equal(fixture.TestUserId, i.CreatedByUserId));
}
[Fact]
public async Task Download_ReturnsExactUploadedBytes_WithDocxContentType()
{
var documentId = Guid.NewGuid();
var bytes = TestDocx.BuildPlainDocx("ไบต์ต้องตรงกันตอนดาวน์โหลด (AC-8 ส่วน backend)");
using var client = fixture.CreateClient();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(bytes, "roundtrip.docx", Guid.NewGuid().ToString()));
var uploaded = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
using var download = await client.GetAsync(
$"api/documents/{documentId}/versions/{uploaded!.VersionId}/download");
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
Assert.Equal(TestDocx.DocxContentType, download.Content.Headers.ContentType!.MediaType);
var downloaded = await download.Content.ReadAsByteArrayAsync();
Assert.Equal(bytes, downloaded); // byte-for-byte ผ่าน MinIO ไป-กลับ
}
[Fact]
public async Task Download_UnknownVersion_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.GetAsync(
$"api/documents/{Guid.NewGuid()}/versions/{Guid.NewGuid()}/download");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetVersions_UnknownDocument_Returns404()
{
using var client = fixture.CreateClient();
using var response = await client.GetAsync($"api/documents/{Guid.NewGuid()}/versions");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Upload_NonDocxFile_Returns400()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var form = new MultipartFormDataContent();
var file = new ByteArrayContent("นี่ไม่ใช่ word"u8.ToArray());
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
form.Add(file, "file", "notes.txt"); // ชื่อไม่ลงท้าย .docx
form.Add(new StringContent(Guid.NewGuid().ToString()), "clientVersionId");
using var response = await client.PostAsync($"api/documents/{documentId}/versions", form);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
await using var db = fixture.CreateDb();
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.Id == documentId)); // ไม่มี side effect
}
[Fact]
public async Task Upload_InvalidClientVersionId_Returns400()
{
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{Guid.NewGuid()}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", "ไม่ใช่ GUID"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Upload_InvalidFieldsJson_Returns400()
{
using var client = fixture.CreateClient();
using var response = await client.PostAsync(
$"api/documents/{Guid.NewGuid()}/versions",
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), "not json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
/// <summary>เอกสารไม่ผูก template → snapshot เก็บ map ดิบตามที่ส่งมา (behavior ของ Phase 2)</summary>
[Fact]
public async Task Upload_WithoutTemplate_StoresRawFieldsMapInSnapshot()
{
var documentId = Guid.NewGuid();
using var client = fixture.CreateClient();
using var upload = await client.PostAsync(
$"api/documents/{documentId}/versions",
ApiExtensions.VersionForm(
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
"""{"custom_tag":"ค่าที่ส่งมา"}"""));
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
var fields = items!.Single().Fields!.Value;
Assert.Equal(JsonValueKind.Object, fields.ValueKind);
Assert.Equal("ค่าที่ส่งมา", fields.GetProperty("custom_tag").GetString());
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Testcontainers.Minio" Version="4.14.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.14.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<!-- realm สำหรับ import เข้า Keycloak container — อ่านตอน runtime ที่ AppContext.BaseDirectory -->
<ItemGroup>
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WordVersioning.Api\WordVersioning.Api.csproj" />
</ItemGroup>
</Project>

View file

@ -123,3 +123,6 @@ static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string ke
// macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
return new X509Certificate2(paired.Export(X509ContentType.Pfx));
}
/// <summary>ให้ WebApplicationFactory&lt;Program&gt; ของ integration tests เข้าถึง top-level Program ได้</summary>
public partial class Program;

View file

@ -0,0 +1,4 @@
<Solution>
<Project Path="WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj" />
<Project Path="WordVersioning.Api/WordVersioning.Api.csproj" />
</Solution>