Phase 7-8
This commit is contained in:
parent
a196b8f9a8
commit
0148f48204
25 changed files with 2473 additions and 47 deletions
51
backend/WordVersioning.Api.IntegrationTests/AuthTests.cs
Normal file
51
backend/WordVersioning.Api.IntegrationTests/AuthTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
147
backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs
Normal file
147
backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
219
backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Normal file
219
backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 เพิ่งขึ้น — รอแล้วลองใหม่
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
236
backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs
Normal file
236
backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
146
backend/WordVersioning.Api.IntegrationTests/TestDocx.cs
Normal file
146
backend/WordVersioning.Api.IntegrationTests/TestDocx.cs
Normal 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));
|
||||
}
|
||||
64
backend/WordVersioning.Api.IntegrationTests/TestDtos.cs
Normal file
64
backend/WordVersioning.Api.IntegrationTests/TestDtos.cs
Normal 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);
|
||||
}
|
||||
74
backend/WordVersioning.Api.IntegrationTests/TestFlow.cs
Normal file
74
backend/WordVersioning.Api.IntegrationTests/TestFlow.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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<Program> ของ integration tests เข้าถึง top-level Program ได้</summary>
|
||||
public partial class Program;
|
||||
|
|
|
|||
4
backend/WordVersioning.slnx
Normal file
4
backend/WordVersioning.slnx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<Solution>
|
||||
<Project Path="WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj" />
|
||||
<Project Path="WordVersioning.Api/WordVersioning.Api.csproj" />
|
||||
</Solution>
|
||||
Loading…
Add table
Add a link
Reference in a new issue