From 0148f48204e7d6c0fc8cd637d94410c50d34f23b Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 20 Aug 2026 13:51:42 +0700 Subject: [PATCH] Phase 7-8 --- CLAUDE.md | 12 +- README.md | 41 +- .../AuthTests.cs | 51 ++ .../FieldSyncTests.cs | 147 ++++++ .../Fixtures/realm-word-versioning-test.json | 184 ++++++++ .../GenerateTests.cs | 219 +++++++++ .../IntegrationTestFixture.cs | 237 ++++++++++ .../TemplateTests.cs | 236 +++++++++ .../TestDocx.cs | 146 ++++++ .../TestDtos.cs | 64 +++ .../TestFlow.cs | 74 +++ .../UploadVersionTests.cs | 171 +++++++ ...WordVersioning.Api.IntegrationTests.csproj | 35 ++ backend/WordVersioning.Api/Program.cs | 3 + backend/WordVersioning.slnx | 4 + docs/acceptance-review.md | 92 ++++ docs/plans/officejs-implementation-plan.md | 61 ++- web/app/composables/useTheme.ts | 6 +- web/package-lock.json | 446 +++++++++++++++++- web/package.json | 7 +- web/test/fieldValidation.spec.ts | 84 ++++ web/test/setup.ts | 27 ++ web/test/snapshot.spec.ts | 77 +++ web/test/useTheme.spec.ts | 84 ++++ web/vitest.config.ts | 12 + 25 files changed, 2473 insertions(+), 47 deletions(-) create mode 100644 backend/WordVersioning.Api.IntegrationTests/AuthTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/Fixtures/realm-word-versioning-test.json create mode 100644 backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/TestDocx.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/TestDtos.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/TestFlow.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/UploadVersionTests.cs create mode 100644 backend/WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj create mode 100644 backend/WordVersioning.slnx create mode 100644 docs/acceptance-review.md create mode 100644 web/test/fieldValidation.spec.ts create mode 100644 web/test/setup.ts create mode 100644 web/test/snapshot.spec.ts create mode 100644 web/test/useTheme.spec.ts create mode 100644 web/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index d2c209a..301e6dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) -**สถานะปัจจุบัน: 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) - `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 @@ -64,8 +65,11 @@ cd backend/WordVersioning.Api 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 dotnet ef migrations add -dotnet test -dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน 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 เดี่ยว # add-in (https://localhost:3000) cd addin @@ -79,5 +83,5 @@ npx jest src/services/metadataService.test.ts # รัน test เดี cd web npm run dev npm test -npx vitest run composables/useTheme.spec.ts # รัน test เดี่ยว +npx vitest run test/useTheme.spec.ts # รัน test เดี่ยว ``` diff --git a/README.md b/README.md index d8ee592..055ac38 100644 --- a/README.md +++ b/README.md @@ -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 เต็มชุด | | 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) | -| 7 | Tests ครบชุด (backend integration + web Vitest) | ⏳ รอ implement | -| 8 | ไล่ acceptance criteria เต็มชุด | ⏳ รอดำเนินการ (ส่วน README เติมครบแล้ว) | +| 7 | Tests ครบชุด (backend integration + web Vitest + addin Jest) | ✅ เสร็จ — 125 tests ผ่านทั้งหมด (ดูหัวข้อ Tests) | +| 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) - [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) @@ -259,6 +260,7 @@ npm run build ```bash cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in) +cd web && npm test # Vitest — unit tests (36 ตัว) ``` เปิด แล้วกดเข้าสู่ระบบด้วย `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` +## 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) ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน: diff --git a/backend/WordVersioning.Api.IntegrationTests/AuthTests.cs b/backend/WordVersioning.Api.IntegrationTests/AuthTests.cs new file mode 100644 index 0000000..c5d5c9c --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/AuthTests.cs @@ -0,0 +1,51 @@ +using System.Net; + +namespace WordVersioning.Api.IntegrationTests; + +/// FR-8 ฝั่ง backend + AC-3 — ทุก endpoint ต้อง [Authorize]: ไม่แนบ token → 401 ทั้งหมด +[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); + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs b/backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs new file mode 100644 index 0000000..629a8a5 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +namespace WordVersioning.Api.IntegrationTests; + +/// FR-11 — field sync ตอน Save & Version: latest values อัปเดต + snapshot ต่อ version +/// + missing-field policy (AC-13: ค่าเปลี่ยนตาม, AC-14: field หาย → ค่าเดิม + mark missing ไม่ fail) +[Collection(IntegrationTestCollection.Name)] +public sealed class FieldSyncTests(IntegrationTestFixture fixture) +{ + /// สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้ + 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); + } + + /// AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม + /// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้) + [Fact] + public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion() + { + var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync(); + + var v2Fields = new Dictionary + { + ["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(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>(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)); + } + + /// AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing + /// ใน snapshot; field ที่ส่งเกินนิยามถูก ignore + [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>(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); // ไม่มี "อะไรที่ไม่มีในนิยาม" + } + + /// FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1) + [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(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); + } + + /// แปลง snapshot JSON (structured {key:{value,missing}}) เป็น dictionary ใช้ใน test + private static Dictionary Snapshot(JsonElement fields) + { + var result = new Dictionary(); + foreach (var prop in fields.EnumerateObject()) + result[prop.Name] = ( + prop.Value.GetProperty("value").GetString() ?? "", + prop.Value.GetProperty("missing").GetBoolean()); + return result; + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/Fixtures/realm-word-versioning-test.json b/backend/WordVersioning.Api.IntegrationTests/Fixtures/realm-word-versioning-test.json new file mode 100644 index 0000000..a413bfb --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/Fixtures/realm-word-versioning-test.json @@ -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 +} \ No newline at end of file diff --git a/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs b/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs new file mode 100644 index 0000000..9a0dab6 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using WordVersioning.Api.Services; + +namespace WordVersioning.Api.IntegrationTests; + +/// FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties +/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template) +[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(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(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(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); + } + + /// FR-12 — detail ของเอกสาร: merge นิยาม × ค่าล่าสุด แบบรองรับ orphan ทั้งสองทาง + /// (template replace ไฟล์แล้ว: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def) + [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(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); + } + + /// FR-12 — รายการเอกสารต่อ template: versionCount/Latest ถูกต้อง เรียงกิจกรรมล่าสุดก่อน + [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>(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); + } + + /// FR-12 — detail ของเอกสารที่ไม่ผูก template (track เองจาก add-in): Fields ว่าง templateId null + [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(IntegrationTestFixture.Json); + + Assert.Null(detail!.TemplateId); + Assert.Null(detail.TemplateName); + Assert.Empty(detail.Fields); + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs new file mode 100644 index 0000000..bc9f607 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/IntegrationTestFixture.cs @@ -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; + +/// ชื่อ collection ที่ใช้ fixture เดียวกัน — ทุก test class ต้อง [Collection(Name)] +/// เพื่อให้รันตามลำดับ (ไม่ parallel) กับ DB/container ชุดเดียวกัน +[CollectionDefinition(Name)] +public sealed class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "integration"; +} + +/// Foundation ของ integration tests: รัน PostgreSQL + MinIO + Keycloak จริงผ่าน +/// Testcontainers (ตามแผน Phase 7) แล้วยิง API ผ่าน WebApplicationFactory ที่ชี้ config ไปที่ container ทั้งหมด +/// +/// - Keycloak: import realm จาก Fixtures/realm-word-versioning-test.json (สำเนาของ dev realm +/// ที่เปิด password grant ให้ client word-versioning-web + ยืดอายุ access token — เฉพาะตอน test) +/// user ของ test = `testuser` ที่มาพร้อม realm (เหมือน dev) → ขอ token จริงผ่าน password grant +/// → backend validate ผ่าน JWKS เต็มขั้นตอน · (ไม่ใช้ master admin API เพราะ master realm +/// บังคับ HTTPS บน token endpoint แม้ใน container http) +/// - PostgreSQL: run EF migrations จริง +/// - MinIO: สร้าง bucket ทั้งสองให้เหมือน init service ของ docker-compose +public sealed class IntegrationTestFixture : IAsyncLifetime +{ + public const string MinioUser = "minioadmin"; + public const string MinioPassword = "minioadmin123"; + public const string TestUserName = "testuser"; + public const string TestUserPassword = "Test1234!"; + + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev + .Build(); + + private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + .WithUsername(MinioUser) + .WithPassword(MinioPassword) + .Build(); + + private readonly IContainer _keycloak = new ContainerBuilder("quay.io/keycloak/keycloak:26.2") + .WithCommand("start-dev", "--import-realm") + .WithPortBinding(8080, assignRandomHostPort: true) + // realm สำหรับ test — เหมือน dev realm แต่เปิด password grant (ทำให้ขอ user token ได้โดยไม่ต้องเดิน PKCE) + // ⚠️ target ของ overload นี้คือ "directory" (FilePath, DirectoryPath) — ชี้ dir แล้วไฟล์เข้าไป + // ในนามเดิม realm-word-versioning-test.json ซึ่ง Keycloak import ทุก *.json ใน dir นี้ + .WithResourceMapping( + new FileInfo(Path.Combine(AppContext.BaseDirectory, "Fixtures", "realm-word-versioning-test.json")), + "/opt/keycloak/data/import") + .Build(); + + private WebApplicationFactory _factory = null!; + private string _accessToken = string.Empty; + + /// userId จาก claim `sub` ของ token ที่ fixture ถืออยู่ — ใช้เทียบกับ CreatedByUserId (AC-5) + public string TestUserId { get; private set; } = string.Empty; + + public string AccessToken => _accessToken; + + public async Task InitializeAsync() + { + // ยก containers ขึ้นพร้อมกัน — Keycloak ช้าสุด (~20-40s) ให้ที่เหลือรันคู่ไปเลย + await Task.WhenAll(_postgres.StartAsync(), _minio.StartAsync(), _keycloak.StartAsync()); + + var keycloakBase = $"http://{_keycloak.Hostname}:{_keycloak.GetMappedPublicPort(8080)}"; + var minioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(9000)}"; + + // Keycloak ใช้เวลา boot ~20-40s — รอจน realm endpoint ตอบ 200 จริงก่อน + // (port เปิดไม่พอ: docker-proxy รับ connection ก่อนแล้วตัดสาย → "response ended prematurely") + try + { + await WaitUntilHttpOk($"{keycloakBase}/realms/word-versioning/.well-known/openid-configuration"); + } + catch (TimeoutException ex) + { + // แนบ log ของ container มาด้วย — ไม่งั้นเห็นแค่ timeout ไม่รู้ว่า realm import พังหรือ boot ช้า + var (stdout, _) = await _keycloak.GetLogsAsync(); + var lines = stdout.Split('\n'); + var tail = string.Join('\n', lines.Length > 40 ? lines[^40..] : lines); + throw new TimeoutException($"{ex.Message}\n--- Keycloak logs (last 40 lines) ---\n{tail}"); + } + + // user มาพร้อม realm (testuser) — ขอ password-grant token แล้วอ่าน `sub` เก็บไว้เทียบกับ CreatedByUserId + _accessToken = await RequestUserTokenAsync(keycloakBase); + TestUserId = ReadJwtClaim(_accessToken, "sub")!; + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + // Production = ไม่อ่าน appsettings.Development.json (DevHttps/cert ฯลฯ) — test ใช้ TestServer ไม่เกี่ยวกับ port จริง + builder.UseEnvironment("Production"); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Default"] = _postgres.GetConnectionString(), + ["MinIO:Endpoint"] = minioEndpoint, + ["MinIO:AccessKey"] = MinioUser, + ["MinIO:SecretKey"] = MinioPassword, + ["MinIO:UseSSL"] = "false", + ["Authentication:Authority"] = $"{keycloakBase}/realms/word-versioning", + ["Authentication:ValidAudiences:0"] = "word-addin-officejs", + ["Authentication:ValidAudiences:1"] = "word-versioning-web", + ["Cors:AllowedOrigins:0"] = "https://localhost:3000", + })); + }); + + // สร้าง schema จริงด้วย EF migrations (ไม่ใช่ EnsureCreated — ต้องผ่าน migration เหมือน production) + await using (var db = CreateDb()) + { + await db.Database.MigrateAsync(); + } + + await CreateBucketsAsync(minioEndpoint); + } + + public async Task DisposeAsync() + { + // null-safe: ถ้า InitializeAsync พังกลางทาง Dispose ต้องไม่พังตาม (กลบ error ตัวจริง) + if (_factory is not null) + await _factory.DisposeAsync(); + await Task.WhenAll( + _postgres.DisposeAsync().AsTask(), + _minio.DisposeAsync().AsTask(), + _keycloak.DisposeAsync().AsTask()); + } + + /// HttpClient ที่แนบ Bearer token ของ test user ไว้แล้ว + public HttpClient CreateClient() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken); + return client; + } + + /// HttpClient ไม่แนบ token — ใช้ตรวจว่าได้ 401 (AC-3) + public HttpClient CreateAnonymousClient() => _factory.CreateClient(); + + /// DbContext ตรงไปที่ test database — สำหรับ assert สถานะใน Postgres จริง + public AppDbContext CreateDb() => + new(new DbContextOptionsBuilder() + .UseNpgsql(_postgres.GetConnectionString()) + .Options); + + /// user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น) + /// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate + /// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ) + private static async Task RequestUserTokenAsync(string keycloakBase) + { + using var http = new HttpClient { BaseAddress = new Uri(keycloakBase) }; + using var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "word-versioning-web", + ["username"] = TestUserName, + ["password"] = TestUserPassword, + }); + using var response = await http.PostAsync("/realms/word-versioning/protocol/openid-connect/token", form); + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(Json); + return payload.GetProperty("access_token").GetString()!; + } + + /// อ่าน claim จาก JWT payload (ไม่ต้อง validate signature — ได้มาจาก Keycloak โดยตรง) + private static string? ReadJwtClaim(string jwt, string claim) + { + var payload = jwt.Split('.')[1]; + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); + return doc.RootElement.TryGetProperty(claim, out var value) ? value.GetString() : null; + } + + /// poll URL จนตอบ 2xx — Keycloak readiness probe แบบไม่ผูกกับ wait strategy API ของ Testcontainers + private static async Task WaitUntilHttpOk(string url, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromMinutes(2); + using var http = new HttpClient(); + var deadline = DateTime.UtcNow + timeout.Value; + while (DateTime.UtcNow < deadline) + { + try + { + using var response = await http.GetAsync(url); + if (response.IsSuccessStatusCode) + return; + } + catch (HttpRequestException) + { + // ยังไม่พร้อม — พยายามต่อ + } + await Task.Delay(1000); + } + throw new TimeoutException($"endpoint ไม่พร้อมภายใน {timeout}: {url}"); + } + + /// สร้าง bucket ทั้งสองแบบ private — ทำสิ่งเดียวกับ init service (mc) ของ docker-compose + /// พร้อม retry เผื่อ MinIO เพิ่งขึ้นและยังไม่รับ request + private async Task CreateBucketsAsync(string minioEndpoint) + { + var config = new AmazonS3Config { ServiceURL = $"http://{minioEndpoint}", ForcePathStyle = true }; + using var s3 = new AmazonS3Client( + new BasicAWSCredentials(MinioUser, MinioPassword), config); + + foreach (var bucket in new[] { "docx-versions", "docx-templates" }) + { + for (var attempt = 1; ; attempt++) + { + try + { + // AWSSDK v4 ไม่มี DoesS3BucketExist — ยิงเลยแล้วถือว่าจบ (bucket ซ้ำ = ถือว่ามีอยู่แล้ว) + try + { + await s3.PutBucketAsync(bucket); + } + catch (AmazonS3Exception ex) when (ex.ErrorCode == "BucketAlreadyOwnedByYou") + { + } + break; + } + catch (AmazonS3Exception) when (attempt < 10) + { + await Task.Delay(500); // MinIO เพิ่งขึ้น — รอแล้วลองใหม่ + } + } + } + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs b/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs new file mode 100644 index 0000000..9222517 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace WordVersioning.Api.IntegrationTests; + +/// FR-9 — template CRUD + auto-detect SDT tags ตอน upload/replace ไฟล์ (AC-11 ส่วน backend) +[Collection(IntegrationTestCollection.Name)] +public sealed class TemplateTests(IntegrationTestFixture fixture) +{ + /// สร้าง template จาก BuildTemplateDocx() — คืน (id, fields ที่ detect ได้) — ใช้ร่วมกับ test อื่น + public static async Task<(Guid TemplateId, List 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(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(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(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(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>(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>(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 + { + ["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(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>(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(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); + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs b/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs new file mode 100644 index 0000000..02c34a2 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/TestDocx.cs @@ -0,0 +1,146 @@ +using System.Text; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace WordVersioning.Api.IntegrationTests; + +/// สร้าง/อ่านไฟล์ .docx สำหรับ test — ไม่ต้องพึ่ง binary file ใน repo +/// สร้างได้ทั้ง template ที่มี content controls (SDT) หลายรูปแบบและไฟล์เปล่าไม่มี SDT +public static class TestDocx +{ + public const string DocxContentType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + /// สร้าง 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) + 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(); + } + + /// template ตัวแปร: มี employee_name + position เท่านั้น — ใช้ตอน replace ไฟล์ (tag เพิ่ม/หาย) + 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(); + } + + /// ไฟล์ .docx ไม่มี content control เลย — จำลองเอกสารธรรมดาที่ track เองผ่าน add-in + 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(); + } + + /// อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate + 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()) + { + 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()) + sb.Append(text.Text); + return sb.ToString(); + } + } + return ""; + } + + /// อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate + /// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้ + 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()) + { + if (sdtTag.Val?.Value != tag) continue; + var sdt = sdtTag.Parent?.Parent; + var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent"); + return content?.Descendants() + .Select(run => ( + string.Concat(run.Descendants().Select(t => t.Text)), + run.RunProperties?.Bold is not null, + run.RunProperties?.Italic is not null)) + .ToList() ?? []; + } + return []; + } + + /// อ่าน custom property (VTLPWSTR) — null เมื่อไม่มี property นั้น + /// ใช้ตรวจ SavedByAddIn / DocumentTrackingId ที่ backend stamp ลงไฟล์ (FR-10) + 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() + .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)); +} diff --git a/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs new file mode 100644 index 0000000..adf2674 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace WordVersioning.Api.IntegrationTests; + +/// DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน) +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 Fields); + +public record TemplateDetailResult(Guid Id, string Name, string? Description, bool IsActive, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, List Fields); + +public record ReplaceFileResult(Guid Id, List Fields, List AddedTags, List 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 Fields); + +public static class ApiExtensions +{ + /// multipart form ของ POST versions: file + clientVersionId + fields (option) + 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; + } + + /// multipart form ของ POST templates / PUT file: name + description (option) + file + 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 ReadJsonAsync(this HttpResponseMessage response) => + response.Content.Headers.ContentType is null + ? default + : await response.Content.ReadFromJsonAsync(IntegrationTestFixture.Json); +} diff --git a/backend/WordVersioning.Api.IntegrationTests/TestFlow.cs b/backend/WordVersioning.Api.IntegrationTests/TestFlow.cs new file mode 100644 index 0000000..c69fd53 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/TestFlow.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Http.Json; + +namespace WordVersioning.Api.IntegrationTests; + +/// setup ที่ใช้ร่วมกันหลาย test class: สร้าง template + ตั้ง field definitions ครบชุด +/// (employee_name = required text, position = text, salary = number, start_date = date) +public static class TestFlow +{ + public static readonly Dictionary DefaultValues = new() + { + ["employee_name"] = "สมหญิง รักงาน", + ["position"] = "วิศวกรซอฟต์แวร์", + ["salary"] = "75000", + ["start_date"] = "2026-08-01", + }; + + public static async Task<(Guid TemplateId, List 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(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(IntegrationTestFixture.Json); + + return (created.Id, detail!.Fields); + } + + /// generate เอกสาร v1 จาก template ที่ตั้งค่าแล้ว — คืน response ของ generate + public static Task GenerateDocumentAsync(HttpClient client, Guid templateId) => + GenerateDocumentAsync(client, templateId, DefaultValues); + + /// generate พร้อมค่าที่กำหนดเอง (ทดสอบ validation หรือค่าเกินนิยาม) + public static async Task GenerateDocumentAsync( + HttpClient client, Guid templateId, Dictionary 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(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", + }; +} diff --git a/backend/WordVersioning.Api.IntegrationTests/UploadVersionTests.cs b/backend/WordVersioning.Api.IntegrationTests/UploadVersionTests.cs new file mode 100644 index 0000000..bc22f10 --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/UploadVersionTests.cs @@ -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; + +/// FR-5 (upload), FR-6 (history), FR-7 (download) — ผ่าน HTTP จริง + ตรวจสถานะใน Postgres ตรงๆ +[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(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(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(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>(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(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); + } + + /// เอกสารไม่ผูก template → snapshot เก็บ map ดิบตามที่ส่งมา (behavior ของ Phase 2) + [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>(IntegrationTestFixture.Json); + + var fields = items!.Single().Fields!.Value; + Assert.Equal(JsonValueKind.Object, fields.ValueKind); + Assert.Equal("ค่าที่ส่งมา", fields.GetProperty("custom_tag").GetString()); + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj b/backend/WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj new file mode 100644 index 0000000..c9e587c --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs index ed54517..1e5e4d6 100644 --- a/backend/WordVersioning.Api/Program.cs +++ b/backend/WordVersioning.Api/Program.cs @@ -123,3 +123,6 @@ static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string ke // macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory return new X509Certificate2(paired.Export(X509ContentType.Pfx)); } + +/// ให้ WebApplicationFactory<Program> ของ integration tests เข้าถึง top-level Program ได้ +public partial class Program; diff --git a/backend/WordVersioning.slnx b/backend/WordVersioning.slnx new file mode 100644 index 0000000..0df59b9 --- /dev/null +++ b/backend/WordVersioning.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md new file mode 100644 index 0000000..df6db53 --- /dev/null +++ b/docs/acceptance-review.md @@ -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 ที่เหลือ) diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 2d8d78a..bb09053 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -1,7 +1,7 @@ # Implementation Plan: Word Versioning Add-in (Office.js + .NET 8) > แผนงานสร้างระบบจาก 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) --- @@ -97,6 +97,14 @@ DocxGenerationService.cs # FR-10: แทนที่ SDT + stamp custom properties appsettings.json 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 nuxt.config.ts # devServer port 5173 tailwind.config.ts # darkMode: 'class' @@ -113,7 +121,10 @@ useAuth.ts # OIDC PKCE + browser redirect ปกติ useApi.ts # fetch wrapper + Bearer token + refresh 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 package.json /keycloak # Phase 1 @@ -392,7 +403,7 @@ POST /api/documents/generate { templateId, values: { : } } **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: { : } } **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: { : } } **เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง 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 - metadataService: stamp ครบ 4 properties, XML delete-then-add, XML fail แล้วไม่ throw, **readContentControlFields แปลง map ถูก (tag ซ้ำ/ว่าง/ไม่มี control)** - apiClient: แนบ Bearer, retry หลัง refresh, ยอมแพ้หลัง retry ครั้งที่ 2 - - fileService: ประกอบ slice หลากขนาดเป็น Blob, เรียก closeAsync -- [ ] **Backend integration (xUnit + Testcontainers):** รัน PostgreSQL + MinIO + Keycloak จริง - - ขอ token ผ่าน Keycloak admin API สร้าง test user - - upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT - - **Template:** upload docx มี SDT → detect tags ครบ; บันทึก field defs; deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้ - - **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **custom properties มี `SavedByAddIn`/`DocumentTrackingId`** + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400 - - **Field sync:** upload version พร้อม `fields` → latest values อัปเดต + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)** -- [ ] **Web (Vitest):** validation ของ dynamic form (required/type) + useTheme logic + - fileService: ประกอบ slice หลากขนาดเป็น Blob (ครบทั้ง 4 รูปแบบของ `slice.data`), เรียก closeAsync +- [x] **Backend integration (xUnit + Testcontainers):** — 42 tests, project ใหม่ `backend/WordVersioning.Api.IntegrationTests` (+ `backend/WordVersioning.slnx` ครอบทั้ง API + tests) + - รัน PostgreSQL + MinIO + Keycloak จริง (image เดียวกับ docker-compose) ผ่าน collection fixture เดียว — `WebApplicationFactory` (ต้องเพิ่ม `public partial class Program;` ท้าย Program.cs) ชี้ config ไปที่ container ทั้งหมด + รัน EF **migrations จริง** + สร้าง bucket ทั้งสอง + - token ของ test user: ใช้ `testuser` ที่มาพร้อม realm import + **password grant** (เปิดให้ client `word-versioning-web` เฉพาะใน realm ของ test) — backend ยัง validate ผ่าน JWKS/issuer/audience เต็มขั้นตอนเหมือน production; `sub` อ่านจาก JWT payload เพื่อเทียบ `CreatedByUserId` + - upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT ✓ ครบใน `UploadVersionTests` + `AuthTests` + - **Template:** upload docx มี SDT → detect tags ครบ (run เดี่ยว/multi-run/block/tag ซ้ำ → dedup); บันทึก field defs (reject key นอกไฟล์ + type ผิด); deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้ ✓ `TemplateTests` + - **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) + - **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` เขียวทั้งคู่ +> ✅ ตรวจผ่านจริง (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 - [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 dotnet run # รัน + swagger ที่ http://localhost:5xxx dotnet ef migrations add -dotnet test +cd .. && dotnet test # integration tests (42 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที) # add-in (https://localhost:3000) cd addin npm install npm run build # webpack build npm start # dev server — sideload ผ่าน manifest นี้ -npm test # jest +npm test # jest (47 ตัว) npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้ # web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in) cd web npm install npm run dev -npm test # vitest +npm test # vitest (36 ตัว) ``` diff --git a/web/app/composables/useTheme.ts b/web/app/composables/useTheme.ts index a7a47ce..6c1102f 100644 --- a/web/app/composables/useTheme.ts +++ b/web/app/composables/useTheme.ts @@ -9,7 +9,9 @@ export function useTheme() { function apply(next: Theme) { 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') // ข้อยกเว้นเดียวที่ localStorage อนุญาต: theme preference ไม่ใช่ credential (กติกา #4) localStorage.setItem(STORAGE_KEY, next) @@ -18,7 +20,7 @@ export function useTheme() { /** Sync state กับที่ inline script ตั้งไว้ตอนโหลดหน้า (client เท่านั้น) */ function initTheme() { - if (!import.meta.client) return + if (typeof window === 'undefined') return const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'light' || stored === 'dark') { theme.value = stored diff --git a/web/package-lock.json b/web/package-lock.json index f57b0ab..3eb963f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,10 @@ "nuxt": "^4.5.2", "vue": "^3.5.41", "vue-router": "^5.2.0" + }, + "devDependencies": { + "happy-dom": "^20.11.6", + "vitest": "^4.1.11" } }, "node_modules/@alloc/quick-lru": { @@ -2828,6 +2832,31 @@ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2840,12 +2869,39 @@ "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "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": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "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": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@unhead/bundler/-/bundler-3.3.2.tgz", @@ -2987,6 +3043,149 @@ "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": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", @@ -3505,6 +3704,16 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -3862,6 +4071,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "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": { "version": "4.1.0", "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -4017,6 +4229,16 @@ ], "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": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4137,16 +4359,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -5046,6 +5258,16 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", @@ -5499,6 +5721,25 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9158,6 +9399,13 @@ "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9277,6 +9525,13 @@ "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": { "version": "2.1.0", "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==", "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": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", @@ -9903,6 +10165,16 @@ "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10075,6 +10347,13 @@ "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": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -10777,6 +11056,106 @@ "@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": { "version": "3.5.41", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", @@ -10953,6 +11332,16 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "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": { "version": "5.0.0", "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_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": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/web/package.json b/web/package.json index 1720615..953eea0 100644 --- a/web/package.json +++ b/web/package.json @@ -7,12 +7,17 @@ "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", - "postinstall": "nuxt prepare" + "postinstall": "nuxt prepare", + "test": "vitest run" }, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", "nuxt": "^4.5.2", "vue": "^3.5.41", "vue-router": "^5.2.0" + }, + "devDependencies": { + "happy-dom": "^20.11.6", + "vitest": "^4.1.11" } } diff --git a/web/test/fieldValidation.spec.ts b/web/test/fieldValidation.spec.ts new file mode 100644 index 0000000..8e95a66 --- /dev/null +++ b/web/test/fieldValidation.spec.ts @@ -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([]) + }) +}) diff --git a/web/test/setup.ts b/web/test/setup.ts new file mode 100644 index 0000000..bfc34ad --- /dev/null +++ b/web/test/setup.ts @@ -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>() + +;(globalThis as Record).useState = (key: string, init: () => T): Ref => { + let entry = sharedState.get(key) + if (!entry) { + entry = ref(init()) as Ref + sharedState.set(key, entry) + } + return entry as Ref +} + +;(globalThis as Record).computed = computed + +beforeEach(() => { + sharedState.clear() + document.documentElement.classList.remove('dark') + localStorage.clear() +}) diff --git a/web/test/snapshot.spec.ts b/web/test/snapshot.spec.ts new file mode 100644 index 0000000..3694e0d --- /dev/null +++ b/web/test/snapshot.spec.ts @@ -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 }]) + }) +}) diff --git a/web/test/useTheme.spec.ts b/web/test/useTheme.spec.ts new file mode 100644 index 0000000..d036a9a --- /dev/null +++ b/web/test/useTheme.spec.ts @@ -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 บน ', () => { + 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) + }) +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..c0c810d --- /dev/null +++ b/web/vitest.config.ts @@ -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'], + }, +})