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

@ -14,12 +14,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–6 เสร็จ** (backend + add-in + web portal ครบ รวม 6.4/FR-12: รายการเอกสารต่อ template, document detail, re-generate — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — งานต่อคือ phase 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน **สถานะปัจจุบัน: Phase 0–8 เสร็จ** (implement ครบ + tests ครบชุด: addin 47 / web 36 / backend integration 42 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–15 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md)
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–12, acceptance criteria 1–15, data models, platform constraints) - `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–12, acceptance criteria 1–15, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC - `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–15 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -64,7 +65,10 @@ cd backend/WordVersioning.Api
dotnet run # API + swagger (ผ่าน launchSettings — เปิด browser ให้) dotnet run # API + swagger (ผ่าน launchSettings — เปิด browser ให้)
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5075 dotnet run --no-launch-profile # แบบไม่เปิด browser — ต้องใส่ env เอง ไม่งั้นไปอ่าน appsettings.json (DB password ว่าง) → 500 ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5075 dotnet run --no-launch-profile # แบบไม่เปิด browser — ต้องใส่ env เอง ไม่งั้นไปอ่าน appsettings.json (DB password ว่าง) → 500
dotnet ef migrations add <Name> dotnet ef migrations add <Name>
dotnet test
# backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที)
cd backend
dotnet test # slnx ครอบทั้ง API + tests (42 tests)
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
# add-in (https://localhost:3000) # add-in (https://localhost:3000)
@ -79,5 +83,5 @@ npx jest src/services/metadataService.test.ts # รัน test เดี
cd web cd web
npm run dev npm run dev
npm test npm test
npx vitest run composables/useTheme.spec.ts # รัน test เดี่ยว npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว
``` ```

View file

@ -41,15 +41,16 @@
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด | | 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
| 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle | | 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle |
| 6.4 | รายการเอกสารต่อ template + document detail + re-generate (FR-12) | ✅ เสร็จ (เพิ่มทีหลัง v4) | | 6.4 | รายการเอกสารต่อ template + document detail + re-generate (FR-12) | ✅ เสร็จ (เพิ่มทีหลัง v4) |
| 7 | Tests ครบชุด (backend integration + web Vitest) | ⏳ รอ implement | | 7 | Tests ครบชุด (backend integration + web Vitest + addin Jest) | ✅ เสร็จ — 125 tests ผ่านทั้งหมด (ดูหัวข้อ Tests) |
| 8 | ไล่ acceptance criteria เต็มชุด | ⏳ รอดำเนินการ (ส่วน README เติมครบแล้ว) | | 8 | ไล่ acceptance criteria เต็มชุด | ✅ เสร็จ — ผลครบ AC-1 ถึง 15 ที่ [docs/acceptance-review.md](docs/acceptance-review.md) |
**ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail), add-in ครบทั้ง services และ task pane UI (unit tests 47 ตัว) ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) · เหลือ: tests ครบชุด (Phase 7) + ไล่ AC ด้วยมือ (Phase 8) **ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail), add-in ครบทั้ง services และ task pane UI ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) · **tests ครบชุด 125 ตัว** (backend integration 42 รันบน infra จริง + addin 47 + web 36) · ไล่ AC 1–15 ครบแล้ว — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้ (Word Online/Windows, click-through บน browser จริง — ดูท้าย [acceptance-review.md](docs/acceptance-review.md))
## เอกสาร ## เอกสาร
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–12), data models, acceptance criteria (1–15) - [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–12), data models, acceptance criteria (1–15)
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done - [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done
- [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 15 ข้อ พร้อมปัญหาที่เจอและรายการที่เหลือต้องยืนยันด้วยมือ
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0) สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
@ -259,6 +260,7 @@ npm run build
```bash ```bash
cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in) cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in)
cd web && npm test # Vitest — unit tests (36 ตัว)
``` ```
เปิด <http://localhost:5173> แล้วกดเข้าสู่ระบบด้วย `testuser` / `Test1234!` (ผ่าน Keycloak — client `word-versioning-web` ใช้ PKCE เหมือนกันทั้งระบบ) หน้าที่มีให้ใช้: เปิด <http://localhost:5173> แล้วกดเข้าสู่ระบบด้วย `testuser` / `Test1234!` (ผ่าน Keycloak — client `word-versioning-web` ใช้ PKCE เหมือนกันทั้งระบบ) หน้าที่มีให้ใช้:
@ -281,6 +283,39 @@ Theme toggle (light/dark) อยู่มุมขวาบนของทุก
**Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID` **Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID`
## Tests
ชุด test ทั้งหมดของระบบ — รวม **125 ตัว** ครอบทุก flow ตาม acceptance criteria:
```bash
# 1) backend integration (42 ตัว) — ต้องมี Docker รันอยู่
cd backend
dotnet test
dotnet test --filter "FullyQualifiedName~FieldSyncTests" # รันเฉพาะชุด (Auth/UploadVersion/Template/Generate/FieldSync)
# 2) add-in unit (47 ตัว) — mock Word.run / Office.context.ui / fetch / Keycloak
cd addin && npm test
npx jest src/services/versionFlow.test.ts # รันเดี่ยว
# 3) web unit (36 ตัว)
cd web && npm test
npx vitest run test/useTheme.spec.ts # รันเดี่ยว
```
**backend integration** (`backend/WordVersioning.Api.IntegrationTests`) — ชุดใหญ่สุด: ยก **PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers** ทุกครั้งที่รัน (~1 นาทีรวมการยก infra — ไม่แตะของ dev ใน docker-compose เลย) แล้วยิง API ผ่าน `WebApplicationFactory` พร้อม token จริงจาก Keycloak (validate JWKS/issuer/audience เต็มขั้นตอน):
- **AuthTests** — ไม่แนบ token / token ขยะ → 401 ครบทุก endpoint (AC-3)
- **UploadVersionTests** — upload v1/v2 + versionNumber เพิ่ม + history เรียงใหม่→เก่า + download ได้ bytes ตรงเป๊ะ + ไฟล์ไม่ใช่ docx → 400 + `CreatedByUserId` ตรง `sub` ใน JWT (AC-5, 6, 8)
- **TemplateTests** — detect SDT tags ครบทุกรูปแบบ (run เดี่ยว/multi-run/block/tag ซ้ำ) + บันทึก field defs + replace ไฟล์ (tag เพิ่ม/หาย) + deactivate แล้ว generate ไม่ได้แต่เอกสารเก่ายังใช้ได้ (AC-11)
- **GenerateTests** — generate แล้วค่าครบทุก field + multi-run รวมเป็น run เดียวรักษา formatting + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ + v1 + field values ใน DB + required/type validation + FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template) (AC-12, 15)
- **FieldSyncTests** — Save & Version พร้อม `fields`: latest values อัปเดต + snapshot ต่อ version (AC-13) · ลบ content control → request สำเร็จ ค่าเดิมคงอยู่ + mark missing (AC-14)
ไฟล์ .docx ที่ใช้ตอน test สร้างใน memory ด้วย OpenXML (`TestDocx.cs`) — ไม่มี binary ติดใน repo · realm ของ Keycloak ทดสอบเป็นสำเนาของ dev (`Fixtures/realm-word-versioning-test.json`) ที่เปิด password grant ให้ client `word-versioning-web` เฉพาะตอน test เพื่อขอ token ได้โดยไม่ต้องเดิน PKCE
**web unit** (`web/test/`) — `fieldValidation.spec.ts` (validate ฟอร์มสร้างเอกสาร: required/number/date ครบ edge cases) · `useTheme.spec.ts` (toggle + persist + system default) · `snapshot.spec.ts` (`normalizeSnapshot` รองรับ snapshot ทั้ง 2 รูปแบบที่หน้า detail ใช้) — รันบน happy-dom โดย stub Nuxt auto-imports เบาๆ (`test/setup.ts`)
**addin unit** (`addin/src/services/*.test.ts`) — ตามที่มาจาก Phase 4–5: PKCE flow, metadata stamp + XML part fail-แบบ-ไม่-throw, apiClient retry-on-401, fileService ประกอบ slice ครบ 4 รูปแบบ, versionFlow 7 ขั้น
## Troubleshooting (จากการทดสอบจริงบน Word for Mac) ## Troubleshooting (จากการทดสอบจริงบน Word for Mac)
ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน: ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน:

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 // macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
return new X509Certificate2(paired.Export(X509ContentType.Pfx)); 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>

92
docs/acceptance-review.md Normal file
View file

@ -0,0 +1,92 @@
# ตรวจ Acceptance Criteria 1–15 (Phase 8)
> วันที่ตรวจ: 2026-08-20 · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
>
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
สรุปรวม: **AC ทั้ง 15 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร
## ผลทีละข้อ
### AC-1 — Sideload บน Word Desktop และ Word Online ได้ทั้งคู่ ไม่มี error ตอนโหลด task pane — 🔶
- 🖐️ **Word for Mac (Desktop): ผ่าน** — sideload ผ่าน `npm start` (manifest symlink) ตรวจตอน Phase 5 พร้อมแก้ปัญหาตกมุม 4 รอบ (dialog https / slice format / mixed content / blob download)
- ✅ อัตโนมัติ: `npm run build` + `npm run lint` + `npm run validate` (manifest) ผ่านทุกครั้ง
- 🔶 **Word Online + Word for Windows: ยังไม่ได้ทดสอบ** (ต้องมีเครื่อง/บัญชีทดสอบ)
### AC-2 — เปิดครั้งแรกยังไม่ login → Sign in → dialog → login สำเร็จ dialog ปิดเอง ได้ token — ✅ (automated) + 🖐️ (Mac)
- ✅ `authService.test.ts` (14 tests): PKCE pair format, state validation, token exchange, refresh, dialog message flow
- 🖐️ ทดสอบจริงบน Word for Mac ผ่าน (Keycloak https:8443 + Office Dialog API)
### AC-3 — เรียก backend โดยไม่แนบ token → 401 — ✅
- `AuthTests` (xUnit + Keycloak จริง): ครบทุก endpoint ทั้ง GET/POST + กรณี token ขยะ + กรณี token จริงได้ 200
### AC-4 — ไฟล์ใหม่ไม่เคย track → ปุ่ม "Start Versioning This Document" — ✅
- `metadataService.test.ts`: ตรวจ `SavedByAddIn` เทียบ ADDIN_SIGNATURE_GUID → state `untracked` (App state machine)
- 🖐️ ยืนยันแล้วบน Word for Mac ตอน Phase 5
### AC-5 — Start Versioning → stamp metadata, สร้าง Document, v1, `CreatedByUserId` ตรง `sub` ใน JWT — ✅
- `UploadVersionTests.FirstUpload_CreatesDocumentAndVersionRow_FromJwtClaims`: token จริงจาก Keycloak → ตรวจ row ใน Postgres ตรงๆ ว่า `CreatedByUserId == sub` + SHA256 + object key format ถูกต้อง
### AC-6 — ปิดแล้วเปิดไฟล์เดิม → ตรวจพบ tracked + โหลด history ถูก — ✅
- `UploadVersionTests.SecondUpload_...` + `GetVersions` ordering (ใหม่ → เก่า) + snapshot ต่อ version
- 🖐️ ยืนยันบน Mac แล้ว (round 3 ของ Phase 5)
### AC-7 — แก้ไขแล้ว Save & Version → version ใหม่ + `LastVersionId`/`LastVersionTimestamp` อัปเดต — ✅
- `versionFlow.test.ts` (7 ขั้นตาม spec ครบ) + metadataService stamp tests + `SecondUpload_IncrementsVersionNumber`
- หมายเหตุจาก Phase 5: `LastVersionId` ในไฟล์ = `clientVersionId` (ต้อง stamp ก่อน `document.save()`)
### AC-8 — ดาวน์โหลด version เก่าได้ไฟล์ที่เปิดได้ เนื้อหาตรงตอน upload — ✅
- `UploadVersionTests.Download_ReturnsExactUploadedBytes_WithDocxContentType`: byte-for-byte ผ่าน MinIO ไป-กลับ + content-type ถูก
- 🖐️ บน Mac ใช้ presigned link + `openBrowserWindow` (แก้จาก blob download ที่ WKWebView ทิ้งเงียบๆ) — ผ่านการตรวจ curl แล้ว ยืนยัน click จริงอีกครั้งพร้อม Word Online
### AC-9 — token หมดอายุกลาง session → silent refresh ผู้ใช้ไม่รู้สึก — ✅
- `apiClient.test.ts`: 401 → refresh → retry ครั้งเดียว → ยัง 401 → สัญญาณกลับหน้า sign-in · `useAuth`/`useApi` ของ web ใช้พฤติกรรมเดียวกัน
- 🔶 รอยืนยันด้วยมือ (ปล่อย session ทิ้งไว้จริง)
### AC-10 — XML Part fail silent บน Mac → ระบบยังทำงานถูก (backend = source of truth) — ✅
- `metadataService.test.ts`: XML write fail → catch ไม่ throw · ทุก history อ่านจาก backend เสมอ
### AC-11 — upload template → detect fields อัตโนมัติ + บันทึก definitions — ✅
- `TemplateTests`: SDT ครบทุกรูปแบบ (run เดี่ยว / multi-run คนละ formatting / block / tag ซ้ำ → dedup) + defaults (label=tag, text, required=false) + PUT บันทึกนิยาม + reject key นอกไฟล์/type ผิด
- 🖐️ หน้าเว็บอัปโหลด: ตรวจ SSR + จำลอง request sequence แล้ว (Phase 6) รอ click-through บน browser
### AC-12 — กรอก form บนเว็บ → docx ค่าครบ formatting ไม่พัง → เปิดใน Word เห็น tracked + v1 ทันที — ✅ (backend) + 🔶 (เต็มรูปแบบรอมือ)
- `GenerateTests.Generate_FillsAllFields_...`: ค่าครบทุก SDT + **multi-run รวมเป็น run เดียวโดยรักษา bold ของ run แรก** + custom properties (`SavedByAddIn` = GUID ตรงเป๊ะ, `DocumentTrackingId` = documentId) + Document/v1/field values ใน Postgres ครบ
- 🖐️ ส่วน "เปิดใน Word แล้ว add-in เห็น v1 ทันที": ตรวจด้วย unzip/OpenXML แล้ว ต้องยืนยัน click จริงบน Word
### AC-13 — แก้ค่าใน content control แล้ว Save & Version → field value ใน DB เปลี่ยน + snapshot ต่อ version — ✅
- `FieldSyncTests.UploadWithChangedFields_...`: latest values อัปเดตเฉพาะ field ที่เปลี่ยน (`SourceVersionId` ชี้ v2) + snapshot v1/v2 เก็บค่าต่างกันตามเวลา (diff ได้)
### AC-14 — ลบ content control แล้ว Save & Version → flow สำเร็จ ค่าเดิมคงอยู่ mark missing ไม่ error — ✅
- `FieldSyncTests.UploadWithMissingFields_...`: request 201 · field ที่หายคงค่า v1 + `SourceVersionId` ไม่ขยับ · snapshot `missing:true` พร้อมค่าเดิม · field เกินนิยามถูก ignore ไม่เก็บ
### AC-15 — หน้ารายการเอกสารของ template + detail (fields + history + snapshot ต่อ version + ดาวน์โหลด) + re-generate เป็น v1 โดยต้นทางไม่ถูกแตะ — ✅
- `GenerateTests.ListDocuments_ShowsVersionCounts_LatestFirst` (versionCount/Latest + เรียงล่าสุดก่อน) + `GetDetail_MergesDefsAndValues_BothOrphanWays` (merge นิยาม × ค่า รองรับ orphan สองทาง) + `GetDetail_OfStandaloneDocument`
- Re-generate: ผ่าน composition เดิม (detail → prefill → generate) — ตรวจ curl จริง Phase 6.4 ว่า **versionCount ของเอกสารต้นทางคงเดิม** + `TemplateTests.Deactivate_...` ยืนยัน policy deactivate
- 🖐️ หน้าเว็บ: SSR ทุก route 200 แล้ว รอ click-through
## ชุด test ที่คุ้ม AC (สรุป)
| ชุด | จำนวน | ครอบ |
|---|---|---|
| backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 42 | AC-3, 5, 6, 7 (backend), 8, 11–15 |
| addin unit (Jest) | 47 | AC-2, 4, 7 (client flow), 9, 10 |
| web unit (Vitest) | 36 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering |
## ปัญหาที่เจอระหว่างไล่ AC (และการแก้)
1. **Keycloak ใน Testcontainers พร้อมช้ากว่า port เปิด** — docker-proxy รับ connection ก่อนแล้วตัดสาย ("response ended prematurely") → fixture poll discovery endpoint จนได้ 200 จริง + แนบ container logs ตอน timeout
2. **`WithResourceMapping` ของ Testcontainers 4.14 รับ target เป็น directory** — ชี้ path ไฟล์ตรงๆ ทำให้ realm ไม่ถูก import (discovery 404 ตลอด 2 นาที) → ชี้ `/opt/keycloak/data/import` แล้วไฟล์เข้าไปชื่อเดิม
3. **Keycloak 26 บังคับ HTTPS บน token endpoint ของ master realm แม้ใน container http** → เลิกใช้ master admin API สร้าง test user ตามแผน ใช้ `testuser` ที่มากับ realm import + password grant แทน (backend ยัง validate JWKS/issuer/audience เต็มขั้น)
4. **template .docx ที่ test สร้างเองผิด schema** — วาง run-level SDT เป็นลูกตรงของ Body (OOXML ห้าม; Word วางไว้ใน paragraph เสมอ) ตอน reload กลายเป็น SdtBlock + unknown elements ทั้งชุด ทำให้ generate เข้า branch ผิด → แก้ `TestDocx` ให้ถูก schema (service ฝั่ง backend ถูกอยู่แล้ว — ยืนยันด้วย template จริงตั้งแต่ Phase 3)
5. **`import.meta.client` ของ Nuxt ไม่ถูก replace ใน Vitest** (vite-node ไม่ apply define กับ import.meta.*) → `useTheme` เปลี่ยน guard เป็น `typeof window !== 'undefined'` (semantics เทียบเท่าใน SSR/client)
6. **ยืนยัน behavior ผ่าน test (ไม่ใช่ bug):** field sync update latest value ของทุก field ที่ "ส่งมา" แม้ค่าไม่เปลี่ยน (`SourceVersionId` ขยับทุกตัวที่ส่ง) — เฉพาะ field ที่ "ไม่ได้ส่ง" เท่านั้นที่คงค่า+source เดิม ตาม policy FR-11
7. (ย้อนอดีต — จาก Phase 5) Office Dialog API ปฏิเสธ http, slice.data หลายรูปแบบ, WKWebView บล็อก mixed content + blob download — แก้ครบแล้ว มี regression coverage ใน unit tests
## ที่เหลือ (ต้องทำด้วยมือ — ทำแทนไม่ได้)
- [ ] Word Online: sideload + login + full flow (AC-1, 2, 4–8 บน Online)
- [ ] Word for Windows (ถ้ามีเครื่อง)
- [ ] ปล่อย session จริงจน token หมดอายุ → ยืนยัน silent refresh (AC-9)
- [ ] Click-through บน browser: สร้าง/แก้ template → สร้างเอกสาร → ดาวน์โหลด → เปิดใน Word ยืนยัน add-in เห็น v1 ทันที (AC-11, 12) + ดาวน์โหลด version ผ่าน presigned link บน Mac อีกครั้ง (AC-8)
- [ ] Theme toggle จำค่าหลัง reload บน browser จริง (Phase 6 DoD ที่เหลือ)

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8) # Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md) > แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: Phase 0–6 เสร็จ (backend + add-in + web portal รวม 6.4/FR-12: รายการเอกสารต่อ template + detail + re-generate — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — ต่อไป: phase 7 (tests ครบชุด), 8 (README ✅ + ไล่ AC) > สถานะ: **Phase 0–8 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync) > วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
--- ---
@ -97,6 +97,14 @@
DocxGenerationService.cs # FR-10: แทนที่ SDT + stamp custom properties DocxGenerationService.cs # FR-10: แทนที่ SDT + stamp custom properties
appsettings.json appsettings.json
WordVersioning.Api.csproj WordVersioning.Api.csproj
/WordVersioning.Api.IntegrationTests # Phase 7 — xUnit + Testcontainers (42 tests)
IntegrationTestFixture.cs # ยก PostgreSQL+MinIO+Keycloak จริง + WebApplicationFactory + migrations + buckets
TestDocx.cs # สร้าง/อ่าน .docx ของ test ใน memory (OpenXML) — ไม่มี binary ใน repo
TestFlow.cs # setup ร่วม: template + field defs + generate
TestDtos.cs # response DTO ฝั่ง test + multipart helpers
Fixtures/realm-word-versioning-test.json # realm ของ test (เปิด password grant เฉพาะ word-versioning-web)
AuthTests.cs / UploadVersionTests.cs / TemplateTests.cs / GenerateTests.cs / FieldSyncTests.cs
WordVersioning.slnx # ครอบทั้ง API + tests → `cd backend && dotnet test`
/web # Phase 0, 6 /web # Phase 0, 6
nuxt.config.ts # devServer port 5173 nuxt.config.ts # devServer port 5173
tailwind.config.ts # darkMode: 'class' tailwind.config.ts # darkMode: 'class'
@ -113,7 +121,10 @@
useAuth.ts # OIDC PKCE + browser redirect ปกติ useAuth.ts # OIDC PKCE + browser redirect ปกติ
useApi.ts # fetch wrapper + Bearer token + refresh useApi.ts # fetch wrapper + Bearer token + refresh
useTheme.ts # light/dark toggle + persist + system default useTheme.ts # light/dark toggle + persist + system default
/utils # oidc / fieldValidation / snapshot — pure functions (unit test ได้) /utils # oidc / fieldValidation / snapshot / format — pure functions (unit test ได้)
/test # Phase 7 — Vitest (fieldValidation/useTheme/snapshot)
setup.ts # stub Nuxt auto-imports (useState/computed) เบาๆ แทน @nuxt/test-utils
vitest.config.ts
/assets/css/tailwind.css /assets/css/tailwind.css
package.json package.json
/keycloak # Phase 1 /keycloak # Phase 1
@ -392,7 +403,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12) **DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
> 🟡 implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · **ยังเหลือ**: ยืนยันการดาวน์โหลดผ่าน presigned link หลังแก้รอบล่าสุด, ไล่ AC เต็มชุด (โดยเฉพาะ AC-9/10/13) และทดสอบบน Word Online/Windows > 🟡→ อัปเดต (2026-08-20): implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · รายการ "ยังเหลือ" เดิมถูกแทนที่: AC-13/10 คุ้มแล้วด้วย integration tests ของ Phase 7 ส่วนที่เหลือ (ยืนยัน presigned link บน Mac อีกครั้ง, Word Online/Windows, AC-9 จริง) รวบอยู่ใน checklist งานมือท้าย **[`docs/acceptance-review.md`](../acceptance-review.md)**
--- ---
@ -442,7 +453,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload **DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · **ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ต้องยืนยันด้วยมือ** ร่วมกับ AC checklist ที่เหลือใน Phase 8 > 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ฝั่ง backend/generate คุ้มแล้วด้วย integration tests (custom properties + v1 ครบ) — **การยืนยันบน Word จริงอยู่ใน checklist งานมือท้าย [`docs/acceptance-review.md`](../acceptance-review.md)** (ตอนนั้นยังต้องรอ Phase 8 ตอนนี้ไล่ครบแล้ว)
--- ---
@ -450,27 +461,43 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่ **เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่
- [ ] **Frontend unit (Jest, ทำพร้อม Phase 4–5):** - [x] **Frontend unit (Jest, ทำพร้อม Phase 4–5):** — 47 tests ผ่าน
- authService: PKCE pair ถูก format, state validation, token exchange, refresh flow - authService: PKCE pair ถูก format, state validation, token exchange, refresh flow
- metadataService: stamp ครบ 4 properties, XML delete-then-add, XML fail แล้วไม่ throw, **readContentControlFields แปลง map ถูก (tag ซ้ำ/ว่าง/ไม่มี control)** - metadataService: stamp ครบ 4 properties, XML delete-then-add, XML fail แล้วไม่ throw, **readContentControlFields แปลง map ถูก (tag ซ้ำ/ว่าง/ไม่มี control)**
- apiClient: แนบ Bearer, retry หลัง refresh, ยอมแพ้หลัง retry ครั้งที่ 2 - apiClient: แนบ Bearer, retry หลัง refresh, ยอมแพ้หลัง retry ครั้งที่ 2
- fileService: ประกอบ slice หลากขนาดเป็น Blob, เรียก closeAsync - fileService: ประกอบ slice หลากขนาดเป็น Blob (ครบทั้ง 4 รูปแบบของ `slice.data`), เรียก closeAsync
- [ ] **Backend integration (xUnit + Testcontainers):** รัน PostgreSQL + MinIO + Keycloak จริง - [x] **Backend integration (xUnit + Testcontainers):** — 42 tests, project ใหม่ `backend/WordVersioning.Api.IntegrationTests` (+ `backend/WordVersioning.slnx` ครอบทั้ง API + tests)
- ขอ token ผ่าน Keycloak admin API สร้าง test user - รัน PostgreSQL + MinIO + Keycloak จริง (image เดียวกับ docker-compose) ผ่าน collection fixture เดียว — `WebApplicationFactory<Program>` (ต้องเพิ่ม `public partial class Program;` ท้าย Program.cs) ชี้ config ไปที่ container ทั้งหมด + รัน EF **migrations จริง** + สร้าง bucket ทั้งสอง
- upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT - token ของ test user: ใช้ `testuser` ที่มาพร้อม realm import + **password grant** (เปิดให้ client `word-versioning-web` เฉพาะใน realm ของ test) — backend ยัง validate ผ่าน JWKS/issuer/audience เต็มขั้นตอนเหมือน production; `sub` อ่านจาก JWT payload เพื่อเทียบ `CreatedByUserId`
- **Template:** upload docx มี SDT → detect tags ครบ; บันทึก field defs; deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้ - upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT ✓ ครบใน `UploadVersionTests` + `AuthTests`
- **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **custom properties มี `SavedByAddIn`/`DocumentTrackingId`** + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400 - **Template:** upload docx มี SDT → detect tags ครบ (run เดี่ยว/multi-run/block/tag ซ้ำ → dedup); บันทึก field defs (reject key นอกไฟล์ + type ผิด); deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้ ✓ `TemplateTests`
- **Field sync:** upload version พร้อม `fields` → latest values อัปเดต + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)** - **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **multi-run รวมเป็น run เดียวรักษา formatting ตัวแรก** + custom properties มี `SavedByAddIn`/`DocumentTrackingId` + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400; ค่าเกินนิยาม → ignore ✓ `GenerateTests` + รวม FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template)
- [ ] **Web (Vitest):** validation ของ dynamic form (required/type) + useTheme logic - **Field sync:** upload version พร้อม `fields` → latest values อัปเดต (เฉพาะ field ที่เปลี่ยน) + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)** ✓ `FieldSyncTests`
- ไฟล์ .docx ของ test สร้างใน memory ด้วย OpenXML (`TestDocx.cs`) — ไม่ต้องเก็บ binary ใน repo
- [x] **Web (Vitest):** — 36 tests, ติดตั้ง `vitest` + `happy-dom` + `test/` (config + setup stub Nuxt auto-imports: useState/computed)
- validation ของ dynamic form (required/type ครบ edge: comma, เครื่องหมาย, วันที่ ISO) ✓ `fieldValidation.spec.ts`
- useTheme logic (toggle + class + persist + system default + state แชร์ตาม key) ✓ `useTheme.spec.ts`
- เพิ่มเติม: `snapshot.spec.ts` ครอบ `normalizeSnapshot` ทั้ง 2 รูปแบบ snapshot (ใช้จริงในหน้า detail)
หมายเหตุจากการทำจริง (2026-08-20):
- **`WithResourceMapping(FileInfo, string)` ของ Testcontainers 4.14 รับ target เป็น "directory" ไม่ใช่ file path** — ชี้ path ไฟล์ตรงๆ แล้วไฟล์จะไปอยู่ใน subdirectory ชื่อแปลกๆ → Keycloak import ไม่เจอ realm → discovery endpoint 404 ตลอด (ต้องชี้ `/opt/keycloak/data/import` แล้วให้ copy ชื่อเดิม)
- **Keycloak 26 บังคับ HTTPS บน token endpoint ของ master realm แม้ใน container http** → ใช้ master admin API สร้าง user ไม่ได้ (แผนเดิม) — เปลี่ยนเป็นใส่ user มากับ realm import เลย + password grant; realm ของ test ตั้ง `sslRequired: none` อยู่แล้วจึงใช้ http ใน container ได้
- Keycloak container "พร้อม" ช้ากว่า port เปิด (docker-proxy รับ connection แล้วตัดสาย → "response ended prematurely") → fixture poll discovery endpoint จนได้ 200 จริง + แนบ container logs ตอน timeout (แก้ปัญหาแบบมีหลักฐานไม่ใช่เดา)
- Testcontainers 4.x: constructor แบบ parameterless ของ module builder เลิกใช้ — ส่ง image ตอน construct (`new PostgreSqlBuilder("postgres:16")`); `GetLogsAsync` คืน tuple `(Stdout, Stderr)`; AWSSDK.S3 v4 ไม่มี `DoesS3BucketExist` (ใช้ PutBucket + จับ BucketAlreadyOwnedByYou)
- **`import.meta.client` ของ Nuxt ไม่ถูก replace ใน Vitest** (vite-node ไม่ apply `define` กับ `import.meta.*`) → `useTheme` เปลี่ยน guard เป็น `typeof window !== 'undefined'` — semantics เทียบเท่าใน SSR/client และ test ได้โดยไม่พึ่ง transform เฉพาะของ Nuxt
- รัน backend tests: `cd backend && dotnet test` (slnx ครอบทั้งสอง project) — ต้องมี Docker รันอยู่ (Testcontainers สร้าง/ทำลาย container เองทุก run — ใช้เวลา ~1 นาทีต้นทุนคงที่สำหรับการยก infra จริง)
**DoD:** `npm test` (addin), `npm test` (web) และ `dotnet test` เขียวทั้งคู่ **DoD:** `npm test` (addin), `npm test` (web) และ `dotnet test` เขียวทั้งคู่
> ✅ ตรวจผ่านจริง (2026-08-20): `cd backend && dotnet test` **42/42** (Testcontainers ยก infra จริงครบทุกครั้ง) · addin `npm test` **47/47** · web `npm test` **36/36** — รวม **125 ตัวเขียวทั้งชุด**
--- ---
## Phase 8: README + ตรวจ acceptance criteria ## Phase 8: README + ตรวจ acceptance criteria
- [x] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9) — ✅ เติมครบแล้ว (2026-08-20) รวม web portal walkthrough ทุกหน้า + re-generate + troubleshooting - [x] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9) — ✅ เติมครบแล้ว (2026-08-20) รวม web portal walkthrough ทุกหน้า + re-generate + troubleshooting
- [ ] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ - [x] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ — ✅ (2026-08-20) ผลครบทั้ง 15 ข้อ + ปัญหาที่เจอ 7 ข้อ + รายการที่เหลือต้องทำด้วยมือ อยู่ที่ **[`docs/acceptance-review.md`](../acceptance-review.md)**
- สรุป: AC ทุกข้อผ่านในระดับตรวจอัตโนมัติ (backend ครอบทุก flow ด้วย integration tests จริง) + ฝั่ง Word for Mac ผ่านการทดสอบจริงตั้งแต่ Phase 5 · เหลือการยืนยันด้วยมือบน Word Online/Windows และ click-through บน browser จริง (ทำแทนไม่ได้)
--- ---
@ -527,19 +554,19 @@ docker compose up -d
cd backend/WordVersioning.Api cd backend/WordVersioning.Api
dotnet run # รัน + swagger ที่ http://localhost:5xxx dotnet run # รัน + swagger ที่ http://localhost:5xxx
dotnet ef migrations add <Name> dotnet ef migrations add <Name>
dotnet test cd .. && dotnet test # integration tests (42 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)
# add-in (https://localhost:3000) # add-in (https://localhost:3000)
cd addin cd addin
npm install npm install
npm run build # webpack build npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest นี้ npm start # dev server — sideload ผ่าน manifest นี้
npm test # jest npm test # jest (47 ตัว)
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้ npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้
# web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in) # web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in)
cd web cd web
npm install npm install
npm run dev npm run dev
npm test # vitest npm test # vitest (36 ตัว)
``` ```

View file

@ -9,7 +9,9 @@ export function useTheme() {
function apply(next: Theme) { function apply(next: Theme) {
theme.value = next theme.value = next
if (import.meta.client) { // ใช้ typeof window แทน import.meta.client — เทียบเท่ากันใน Nuxt (SSR ไม่มี window)
// และรันได้ใน vitest ตรงๆ โดยไม่ต้องพึ่งการ transform เฉพาะของ Nuxt
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', next === 'dark') document.documentElement.classList.toggle('dark', next === 'dark')
// ข้อยกเว้นเดียวที่ localStorage อนุญาต: theme preference ไม่ใช่ credential (กติกา #4) // ข้อยกเว้นเดียวที่ localStorage อนุญาต: theme preference ไม่ใช่ credential (กติกา #4)
localStorage.setItem(STORAGE_KEY, next) localStorage.setItem(STORAGE_KEY, next)
@ -18,7 +20,7 @@ export function useTheme() {
/** Sync state กับที่ inline script ตั้งไว้ตอนโหลดหน้า (client เท่านั้น) */ /** Sync state กับที่ inline script ตั้งไว้ตอนโหลดหน้า (client เท่านั้น) */
function initTheme() { function initTheme() {
if (!import.meta.client) return if (typeof window === 'undefined') return
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') { if (stored === 'light' || stored === 'dark') {
theme.value = stored theme.value = stored

446
web/package-lock.json generated
View file

@ -11,6 +11,10 @@
"nuxt": "^4.5.2", "nuxt": "^4.5.2",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
"vitest": "^4.1.11"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -2828,6 +2832,31 @@
"integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2840,12 +2869,39 @@
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@unhead/bundler": { "node_modules/@unhead/bundler": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@unhead/bundler/-/bundler-3.3.2.tgz", "resolved": "https://registry.npmjs.org/@unhead/bundler/-/bundler-3.3.2.tgz",
@ -2987,6 +3043,149 @@
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue-macros/common": { "node_modules/@vue-macros/common": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz",
@ -3505,6 +3704,16 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-kit": { "node_modules/ast-kit": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@ -3862,6 +4071,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -3905,16 +4127,6 @@
} }
} }
}, },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"extraneous": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cache-content-type": { "node_modules/cache-content-type": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz",
@ -4017,6 +4229,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4137,16 +4359,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"extraneous": true,
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -5046,6 +5258,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "url": "https://github.com/sindresorhus/execa?sponsor=1"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
@ -5499,6 +5721,25 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/happy-dom": {
"version": "20.11.6",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz",
"integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -9158,6 +9399,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -9277,6 +9525,13 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": { "node_modules/standard-as-callback": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@ -9869,6 +10124,13 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyclip": { "node_modules/tinyclip": {
"version": "0.1.15", "version": "0.1.15",
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
@ -9903,6 +10165,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -10075,6 +10347,13 @@
"node": ">=22.19.0" "node": ">=22.19.0"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unenv": { "node_modules/unenv": {
"version": "2.0.0-rc.24", "version": "2.0.0-rc.24",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
@ -10777,6 +11056,106 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.41", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
@ -10953,6 +11332,16 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@ -10978,6 +11367,23 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",

View file

@ -7,12 +7,17 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"nuxt": "^4.5.2", "nuxt": "^4.5.2",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
"vitest": "^4.1.11"
} }
} }

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { validateFieldValues, type FieldDefLike } from '../app/utils/fieldValidation'
/** ชุดนิยามครบทุก type ที่ระบบรองรับ (ตาม TemplateField: text/number/date) */
const defs: FieldDefLike[] = [
{ key: 'employee_name', label: 'ชื่อพนักงาน', type: 'text', isRequired: true },
{ key: 'position', label: 'ตำแหน่ง', type: 'text', isRequired: false },
{ key: 'salary', label: 'เงินเดือน', type: 'number', isRequired: false },
{ key: 'start_date', label: 'วันเริ่มงาน', type: 'date', isRequired: false },
]
describe('validateFieldValues — dynamic form ของหน้าสร้างเอกสาร (FR-10)', () => {
it('ค่าครบและถูก type ทุก field → ไม่มี issue', () => {
const issues = validateFieldValues(defs, {
employee_name: 'สมหญิง รักงาน',
position: 'วิศวกรซอฟต์แวร์',
salary: '75000',
start_date: '2026-08-01',
})
expect(issues).toEqual([])
})
describe('required', () => {
it('required ขาด (ไม่มี key) → issue ชี้ field นั้น', () => {
const issues = validateFieldValues(defs, { salary: '1' })
expect(issues).toHaveLength(1)
expect(issues[0].key).toBe('employee_name')
expect(issues[0].message).toContain('ชื่อพนักงาน')
})
it('required ว่างเปล่า / มีแต่วรรค → issue (ค่าที่เป็นช่องว่างนับเป็นว่าง)', () => {
expect(validateFieldValues(defs, { employee_name: '' })[0].key).toBe('employee_name')
expect(validateFieldValues(defs, { employee_name: ' ' })[0].key).toBe('employee_name')
})
it('ไม่ required และว่าง → ผ่าน (ไม่บังคับ type ด้วย)', () => {
const issues = validateFieldValues(defs, {
employee_name: 'มีชื่อ',
position: '',
salary: '',
start_date: '',
})
expect(issues).toEqual([])
})
})
describe('type = number (ตรง NumberStyles.Number ของ backend)', () => {
it.each(['1', '-5', '+3.14', '1,234.56', '0'])('ค่า %s ผ่าน', (value) => {
expect(validateFieldValues(defs, { employee_name: 'x', salary: value })).toEqual([])
})
it.each(['abc', '1.2.3', '12.', '.5', '1e5', '--2'])('ค่า %s เป็น issue', (value) => {
const issues = validateFieldValues(defs, { employee_name: 'x', salary: value })
expect(issues).toHaveLength(1)
expect(issues[0].message).toContain('ตัวเลข')
})
})
describe('type = date', () => {
it.each(['2026-08-01', '2026-08-01T09:00:00Z', '2026-12-31'])('ค่า %s ผ่าน', (value) => {
expect(validateFieldValues(defs, { employee_name: 'x', start_date: value })).toEqual([])
})
it.each(['not-a-date', '31/02/2026 ผิดรูป', 'สิงหาคม'])('ค่า %s เป็น issue', (value) => {
const issues = validateFieldValues(defs, { employee_name: 'x', start_date: value })
expect(issues).toHaveLength(1)
expect(issues[0].message).toContain('วันที่')
})
})
it('ผิดหลาย field พร้อมกัน → issue เรียงตามลำดับ defs และ issue ละ field เดียว', () => {
const issues = validateFieldValues(defs, {
employee_name: 'มีชื่อแล้ว', // required ผ่าน เหลือดูเฉพาะ type ของ salary/start_date
salary: 'abc',
start_date: 'วันนี้',
})
expect(issues.map((i) => i.key)).toEqual(['salary', 'start_date'])
})
it('type อื่นที่ไม่รู้จัก (เช่น จาก backend อนาคต) → ไม่ validate เพิ่ม', () => {
const futureDefs: FieldDefLike[] = [{ key: 'x', label: 'อะไรใหม่', type: 'email', isRequired: false }]
expect(validateFieldValues(futureDefs, { x: 'ไม่ใช่ email' })).toEqual([])
})
})

27
web/test/setup.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Stub Nuxt auto-imports ที่ composable ใช้ — เบากว่าการลาก @nuxt/test-utils ทั้งชุดเข้ามา
* - useState: ตาม semantics ของ Nuxt คือ state แชร์กันตาม key (สร้างครั้งแรก ครั้งถัดไปได้ตัวเดิม)
* - computed: ของ vue ตรงๆ
* เคลียร์ state ทุก test เพื่อไม่ให้ theme ของ test ก่อนหน้ารั่วมา
*/
import { beforeEach } from 'vitest'
import { computed, ref, type Ref } from 'vue'
const sharedState = new Map<string, Ref<unknown>>()
;(globalThis as Record<string, unknown>).useState = <T>(key: string, init: () => T): Ref<T> => {
let entry = sharedState.get(key)
if (!entry) {
entry = ref(init()) as Ref<unknown>
sharedState.set(key, entry)
}
return entry as Ref<T>
}
;(globalThis as Record<string, unknown>).computed = computed
beforeEach(() => {
sharedState.clear()
document.documentElement.classList.remove('dark')
localStorage.clear()
})

77
web/test/snapshot.spec.ts Normal file
View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { normalizeSnapshot } from '../app/utils/snapshot'
describe('normalizeSnapshot — แปลง snapshot 2 รูปแบบของ backend ให้เป็น list สำหรับ UI (FR-12)', () => {
it('รูปแบบเอกสารผูก template: { key: { value, missing } }', () => {
const result = normalizeSnapshot({
employee_name: { value: 'สมหญิง รักงาน', missing: false },
position: { value: 'วิศวกรเดิม', missing: true },
})
expect(result).toEqual([
{ key: 'employee_name', value: 'สมหญิง รักงาน', missing: false },
{ key: 'position', value: 'วิศวกรเดิม', missing: true },
])
})
it('รูปแบบเอกสารไม่ผูก template: map ดิบ { key: value } → missing=false ทั้งหมด', () => {
const result = normalizeSnapshot({ any_tag: 'ค่าอะไรก็ได้' })
expect(result).toEqual([{ key: 'any_tag', value: 'ค่าอะไรก็ได้', missing: false }])
})
it('value ไม่ใช่ string → แปลงเป็น string; null/undefined → ""', () => {
const result = normalizeSnapshot({
number_value: 123,
bool_value: true,
null_value: null,
undefined_value: undefined,
object_value: { value: null, missing: false },
})
expect(result).toEqual([
{ key: 'number_value', value: '123', missing: false },
{ key: 'bool_value', value: 'true', missing: false },
{ key: 'null_value', value: '', missing: false },
{ key: 'undefined_value', value: '', missing: false },
{ key: 'object_value', value: '', missing: false },
])
})
it('fields เป็น null/undefined/ค่าไม่ใช่ object → list ว่าง (version ไม่มี snapshot)', () => {
expect(normalizeSnapshot(null)).toEqual([])
expect(normalizeSnapshot(undefined)).toEqual([])
expect(normalizeSnapshot('string')).toEqual([])
expect(normalizeSnapshot(42)).toEqual([])
})
it('orderKeys กำหนดลำดับตาม field defs — key นอก defs ต่อท้ายตามตัวอักษร', () => {
const result = normalizeSnapshot(
{ orphan: '1', salary: '2', employee_name: '3', zzz: '4', position: '5' },
['employee_name', 'position', 'salary'],
)
expect(result.map((e) => e.key)).toEqual([
'employee_name',
'position',
'salary',
'orphan',
'zzz', // orphan สองตัวเรียงตาม localeCompare
])
})
it('orderKeys ว่าง/ไม่ส่ง → คงลำดับเดิมของ object', () => {
const fields = { b: 'x', a: 'y' }
expect(normalizeSnapshot(fields, []).map((e) => e.key)).toEqual(['b', 'a'])
expect(normalizeSnapshot(fields).map((e) => e.key)).toEqual(['b', 'a'])
})
it('key ซ้ำใน object (ผ่าน JSON เข้ามา) — ตัวหลังชนะตามพฤติกรรม Object.entries', () => {
// JSON.parse ทำให้ key ซ้ำเหลือตัวเดียวอยู่แล้ว — assert กัน regression เรื่อง Map dedupe
const parsed = JSON.parse('{"k":"first","k":"second"}')
const result = normalizeSnapshot(parsed)
expect(result).toEqual([{ key: 'k', value: 'second', missing: false }])
})
})

84
web/test/useTheme.spec.ts Normal file
View file

@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTheme } from '../app/composables/useTheme'
/** กำหนด prefers-color-scheme ของระบบ — initTheme ใช้เป็นค่า default เมื่อยังไม่มี preference */
function mockPrefersDark(matches: boolean) {
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches }))
}
describe('useTheme — light/dark toggle + persist + system default (Phase 6.1)', () => {
beforeEach(() => {
vi.unstubAllGlobals()
})
it('state เริ่มที่ light และ toggle สลับไป-กลับ พร้อม class บน <html>', () => {
mockPrefersDark(false)
const { theme, isDark, toggle } = useTheme()
expect(theme.value).toBe('light')
expect(isDark.value).toBe(false)
toggle()
expect(theme.value).toBe('dark')
expect(isDark.value).toBe(true)
expect(document.documentElement.classList.contains('dark')).toBe(true)
toggle()
expect(theme.value).toBe('light')
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('toggle เขียน preference ลง localStorage (ข้อยกเว้นเดียวที่อนุญาต — ไม่ใช่ credential)', () => {
mockPrefersDark(false)
const { toggle } = useTheme()
toggle()
expect(localStorage.getItem('theme-preference')).toBe('dark')
toggle()
expect(localStorage.getItem('theme-preference')).toBe('light')
})
it('initTheme ใช้ค่าที่เคยบันทึกไว้ (แม้ระบบเป็นอีกค่า)', () => {
mockPrefersDark(true)
localStorage.setItem('theme-preference', 'light')
const { theme, initTheme } = useTheme()
initTheme()
expect(theme.value).toBe('light') // stored ชนะ system
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('initTheme ไม่มีค่าเก็บไว้ → ตาม prefers-color-scheme ของระบบ', () => {
mockPrefersDark(true)
const { theme, isDark, initTheme } = useTheme()
initTheme()
expect(theme.value).toBe('dark')
expect(isDark.value).toBe(true)
expect(document.documentElement.classList.contains('dark')).toBe(true)
})
it('ค่าใน localStorage ที่ไม่ใช่ light/dark → ตกไปใช้ค่าระบบแทน (กันค่าเสีย)', () => {
mockPrefersDark(false)
localStorage.setItem('theme-preference', 'neon')
const { theme, initTheme } = useTheme()
initTheme()
expect(theme.value).toBe('light') // ค่าเสียถูกเมิน ใช้ system default
})
it('state แชร์กันตาม key (semantics ของ useState) — สอง instance เห็นค่าเดียวกัน', () => {
mockPrefersDark(false)
const first = useTheme()
const second = useTheme()
first.toggle()
expect(second.theme.value).toBe('dark')
expect(second.isDark.value).toBe(true)
})
})

12
web/vitest.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
// ไม่ดึง @nuxt/test-utils — stub เฉพาะ auto-imports ที่ใช้จริง (test/setup.ts) ให้เบาและอธิบายตัวเอง
// (useTheme แยก client/server ด้วย typeof window จึงไม่ต้อง replace import.meta.client)
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: ['./test/setup.ts'],
include: ['test/**/*.spec.ts'],
},
})