Phase 7-8
This commit is contained in:
parent
a196b8f9a8
commit
0148f48204
25 changed files with 2473 additions and 47 deletions
10
CLAUDE.md
10
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,7 +65,10 @@ 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 <Name>
|
||||
dotnet test
|
||||
|
||||
# backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที)
|
||||
cd backend
|
||||
dotnet test # slnx ครอบทั้ง API + tests (42 tests)
|
||||
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
|
||||
|
||||
# add-in (https://localhost:3000)
|
||||
|
|
@ -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 เดี่ยว
|
||||
```
|
||||
|
|
|
|||
41
README.md
41
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 ตัว)
|
||||
```
|
||||
|
||||
เปิด <http://localhost:5173> แล้วกดเข้าสู่ระบบด้วย `testuser` / `Test1234!` (ผ่าน Keycloak — client `word-versioning-web` ใช้ PKCE เหมือนกันทั้งระบบ) หน้าที่มีให้ใช้:
|
||||
|
|
@ -281,6 +283,39 @@ Theme toggle (light/dark) อยู่มุมขวาบนของทุก
|
|||
|
||||
**Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID`
|
||||
|
||||
## 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 รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน:
|
||||
|
|
|
|||
51
backend/WordVersioning.Api.IntegrationTests/AuthTests.cs
Normal file
51
backend/WordVersioning.Api.IntegrationTests/AuthTests.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Net;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-8 ฝั่ง backend + AC-3 — ทุก endpoint ต้อง [Authorize]: ไม่แนบ token → 401 ทั้งหมด</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class AuthTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("GET", "api/templates")]
|
||||
[InlineData("POST", "api/templates")]
|
||||
[InlineData("GET", "api/templates/{0}/documents")]
|
||||
[InlineData("POST", "api/documents/generate")]
|
||||
[InlineData("GET", "api/documents/{0}/versions")]
|
||||
[InlineData("POST", "api/documents/{0}/versions")]
|
||||
[InlineData("GET", "api/documents/{0}/versions/{1}/download")]
|
||||
[InlineData("GET", "api/documents/{0}")]
|
||||
public async Task WithoutToken_AllEndpoints_Return401(string method, string routeTemplate)
|
||||
{
|
||||
using var client = fixture.CreateAnonymousClient();
|
||||
var route = routeTemplate
|
||||
.Replace("{0}", Guid.NewGuid().ToString())
|
||||
.Replace("{1}", Guid.NewGuid().ToString());
|
||||
|
||||
using var request = new HttpRequestMessage(new HttpMethod(method), route);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithGarbageToken_Returns401()
|
||||
{
|
||||
using var client = fixture.CreateAnonymousClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "not-a-real-jwt");
|
||||
|
||||
using var response = await client.GetAsync("api/templates");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithValidToken_Returns200()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.GetAsync("api/templates");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
147
backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs
Normal file
147
backend/WordVersioning.Api.IntegrationTests/FieldSyncTests.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-11 — field sync ตอน Save & Version: latest values อัปเดต + snapshot ต่อ version
|
||||
/// + missing-field policy (AC-13: ค่าเปลี่ยนตาม, AC-14: field หาย → ค่าเดิม + mark missing ไม่ fail)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class FieldSyncTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
/// <summary>สร้างเอกสารจาก template ที่ตั้งค่าแล้ว (v1 ค่า DefaultValues) — จุดตั้งต้นของทุก test ในคลาสนี้</summary>
|
||||
private async Task<(HttpClient Client, Guid DocumentId, Guid V1VersionId)> CreateTrackedDocumentAsync()
|
||||
{
|
||||
var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "field sync");
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
return (client, doc.DocumentId, doc.VersionId);
|
||||
}
|
||||
|
||||
/// <summary>AC-13 — แก้ค่าใน content control แล้ว Save & Version → latest values เปลี่ยนตาม
|
||||
/// + snapshot ของ version นั้นเก็บค่าใหม่ และ v1 snapshot ยังเก็บค่าเก่าไว้ (diff ได้)</summary>
|
||||
[Fact]
|
||||
public async Task UploadWithChangedFields_UpdatesLatestValues_AndSnapshotsPerVersion()
|
||||
{
|
||||
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
|
||||
|
||||
var v2Fields = new Dictionary<string, string>
|
||||
{
|
||||
["employee_name"] = "สมหญิง เปลี่ยนชื่อแล้ว",
|
||||
["position"] = "วิศวกรซอฟต์แวร์", // ไม่เปลี่ยน
|
||||
["salary"] = "99000",
|
||||
["start_date"] = "2026-08-01", // ไม่เปลี่ยน
|
||||
};
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(
|
||||
TestDocx.BuildPlainDocx("แก้ไขใน Word"), "doc.docx", Guid.NewGuid().ToString(),
|
||||
JsonSerializer.Serialize(v2Fields)));
|
||||
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||
var v2 = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
|
||||
// latest values: field ที่ "ส่งมา" ถูก update ทุกตัว (แม้ค่าเดิมไม่เปลี่ยน — ไม่มี dirty check)
|
||||
// จึงชี้ SourceVersionId = v2 ทั้งหมด; ตัวที่ "ไม่ได้ส่ง" เท่านั้นที่คง v1 (ดู test AC-14)
|
||||
await using var db = fixture.CreateDb();
|
||||
var values = await db.DocumentFieldValues.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.ToDictionaryAsync(v => v.FieldKey);
|
||||
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", values["employee_name"].Value);
|
||||
Assert.Equal(v2!.VersionId, values["employee_name"].SourceVersionId);
|
||||
Assert.Equal("99000", values["salary"].Value);
|
||||
Assert.Equal(v2.VersionId, values["salary"].SourceVersionId);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value); // ค่าเดิมส่งมาเท่าเดิม → คงเดิมแต่ source ขยับตาม v2
|
||||
Assert.Equal(v2.VersionId, values["position"].SourceVersionId);
|
||||
Assert.Equal(fixture.TestUserId, values["employee_name"].UpdatedByUserId);
|
||||
|
||||
// snapshot ต่อ version ผ่าน history API: v2 = ค่าใหม่, v1 = ค่าเดิม (missing=false ทั้งคู่)
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = (await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json))!
|
||||
.OrderBy(i => i.VersionNumber)
|
||||
.ToList();
|
||||
|
||||
var v1Snapshot = Snapshot(items[0].Fields!.Value);
|
||||
var v2Snapshot = Snapshot(items[1].Fields!.Value);
|
||||
Assert.Equal("สมหญิง รักงาน", v1Snapshot["employee_name"].Value);
|
||||
Assert.Equal("สมหญิง เปลี่ยนชื่อแล้ว", v2Snapshot["employee_name"].Value);
|
||||
Assert.Equal("99000", v2Snapshot["salary"].Value);
|
||||
Assert.All(v1Snapshot.Values, e => Assert.False(e.Missing));
|
||||
Assert.All(v2Snapshot.Values, e => Assert.False(e.Missing));
|
||||
}
|
||||
|
||||
/// <summary>AC-14 — ลบ content control แล้ว Save & Version: request สำเร็จ, ค่าเดิมคงอยู่ + mark missing
|
||||
/// ใน snapshot; field ที่ส่งเกินนิยามถูก ignore</summary>
|
||||
[Fact]
|
||||
public async Task UploadWithMissingFields_KeepsOldValues_MarksMissing_DoesNotFail()
|
||||
{
|
||||
var (client, documentId, v1VersionId) = await CreateTrackedDocumentAsync();
|
||||
|
||||
// ผู้ใช้ลบ control ของ position/salary/start_date — เหลือส่ง employee_name เท่านั้น + ส่งเกิน 1 field
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(
|
||||
TestDocx.BuildPlainDocx("ลบ control ไป"), "doc.docx", Guid.NewGuid().ToString(),
|
||||
"""{"employee_name":"สมหญิง ยังอยู่","อะไรที่ไม่มีในนิยาม":"ส่งเกินมา"}"""));
|
||||
|
||||
// policy FR-11: ไม่ fail request
|
||||
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||
|
||||
// latest values ของ field ที่หาย: คงค่า v1 เดิมไว้ทั้งหมด (รวม SourceVersionId ที่ยังชี้ v1)
|
||||
await using var db = fixture.CreateDb();
|
||||
var values = await db.DocumentFieldValues.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.ToDictionaryAsync(v => v.FieldKey);
|
||||
Assert.Equal("สมหญิง ยังอยู่", values["employee_name"].Value);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", values["position"].Value);
|
||||
Assert.Equal(v1VersionId, values["position"].SourceVersionId); // ไม่ถูกแตะเพราะไม่ได้ส่งมา
|
||||
Assert.Equal("75000", values["salary"].Value);
|
||||
Assert.Equal("2026-08-01", values["start_date"].Value);
|
||||
Assert.Equal(4, values.Count); // field เกินไม่ถูกเก็บ
|
||||
|
||||
// snapshot v2: field ที่หาย missing=true พร้อมค่าเดิม, ไม่มี key ของ field เกิน
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
|
||||
var snapshot = Snapshot(items!.Single(i => i.VersionNumber == 2).Fields!.Value);
|
||||
|
||||
Assert.False(snapshot["employee_name"].Missing);
|
||||
Assert.True(snapshot["position"].Missing);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", snapshot["position"].Value); // ค่าเดิม
|
||||
Assert.True(snapshot["salary"].Missing);
|
||||
Assert.True(snapshot["start_date"].Missing);
|
||||
Assert.Equal(4, snapshot.Count); // ไม่มี "อะไรที่ไม่มีในนิยาม"
|
||||
}
|
||||
|
||||
/// <summary>FR-12 ผ่าน detail — หลัง sync แล้วหน้า detail ต้องเห็นค่าล่าสุด (ไม่ใช่ของ v1)</summary>
|
||||
[Fact]
|
||||
public async Task DetailReflectsLatestSyncedValues()
|
||||
{
|
||||
var (client, documentId, _) = await CreateTrackedDocumentAsync();
|
||||
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(
|
||||
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
|
||||
"""{"employee_name":"ชื่อใหม่สุด","position":"ตำแหน่งใหม่สุด","salary":"1","start_date":"2026-08-20"}"""));
|
||||
upload.EnsureSuccessStatusCode();
|
||||
|
||||
using var detail = await client.GetAsync($"api/documents/{documentId}");
|
||||
var body = await detail.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
|
||||
var byKey = body!.Fields.ToDictionary(f => f.Key);
|
||||
|
||||
Assert.Equal("ชื่อใหม่สุด", byKey["employee_name"].Value);
|
||||
Assert.Equal("ตำแหน่งใหม่สุด", byKey["position"].Value);
|
||||
Assert.Equal("1", byKey["salary"].Value);
|
||||
}
|
||||
|
||||
/// <summary>แปลง snapshot JSON (structured {key:{value,missing}}) เป็น dictionary ใช้ใน test</summary>
|
||||
private static Dictionary<string, (string Value, bool Missing)> Snapshot(JsonElement fields)
|
||||
{
|
||||
var result = new Dictionary<string, (string, bool)>();
|
||||
foreach (var prop in fields.EnumerateObject())
|
||||
result[prop.Name] = (
|
||||
prop.Value.GetProperty("value").GetString() ?? "",
|
||||
prop.Value.GetProperty("missing").GetBoolean());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
{
|
||||
"realm": "word-versioning",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"resetPasswordAllowed": false,
|
||||
"bruteForceProtected": false,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "word-addin-officejs",
|
||||
"name": "Word Versioning Add-in (Office.js task pane)",
|
||||
"description": "Public client สำหรับ task pane — OIDC Authorization Code + PKCE S256 ผ่าน Office Dialog API (redirect กลับ auth-callback.html)",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"https://localhost:3000/auth-callback.html"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://localhost:3000"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-addin-officejs",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "word-versioning-web",
|
||||
"name": "Word Versioning Web Portal (Nuxt)",
|
||||
"description": "Public client สำหรับเว็บ portal — OIDC browser redirect ปกติ (top-level page) + PKCE S256",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:5173"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-versioning-web",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "word-addin-vsto",
|
||||
"name": "Word Versioning Add-in (VSTO desktop)",
|
||||
"description": "Pre-register ไว้ตาม spec (spec-vsto.md ยังไม่มีงาน implement) — public + PKCE, redirect เป็น loopback ตาม RFC 8252",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-addin-vsto",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "testuser",
|
||||
"enabled": true,
|
||||
"email": "testuser@word-versioning.local",
|
||||
"emailVerified": true,
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "Test1234!",
|
||||
"temporary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"accessTokenLifespan": 3600
|
||||
}
|
||||
219
backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Normal file
219
backend/WordVersioning.Api.IntegrationTests/GenerateTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WordVersioning.Api.Services;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-10 — generate เอกสารจาก template: ค่าครบ + formatting รักษาไว้ + stamp custom properties
|
||||
/// + Document/v1/field values ใน flow เดียว (AC-12 ส่วน backend) + FR-12 (detail / รายการเอกสารต่อ template)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class GenerateTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generate_FillsAllFields_PreservesFormatting_StampsProperties_AndCreatesV1()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "generate หลัก");
|
||||
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
|
||||
// response ครบตาม FR-10 + downloadUrl ใช้ได้ทันที
|
||||
Assert.Equal(1, doc.VersionNumber);
|
||||
Assert.Equal($"/api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download", doc.DownloadUrl);
|
||||
using var download = await client.GetAsync(doc.DownloadUrl);
|
||||
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
|
||||
var fileBytes = await download.Content.ReadAsByteArrayAsync();
|
||||
|
||||
// (1) ค่าทุก field ถูกแทนที่ใน SDT
|
||||
Assert.Equal("สมหญิง รักงาน", TestDocx.ReadSdtText(fileBytes, "employee_name"));
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(fileBytes, "position"));
|
||||
Assert.Equal("75000", TestDocx.ReadSdtText(fileBytes, "salary"));
|
||||
Assert.Equal("2026-08-01", TestDocx.ReadSdtText(fileBytes, "start_date"));
|
||||
|
||||
// (2) multi-run SDT: ค่าใหม่เขียนที่ Text แรก (ใน Run แรก — สืบทอด rPr bold ของ template)
|
||||
// และ Text ของ run หลังถูกลบ (เหลือ run โล่งที่มีแต่ rPr — มองไม่เห็น ไม่มีผลกับเนื้อหา)
|
||||
var positionRuns = TestDocx.ReadSdtRuns(fileBytes, "position");
|
||||
var runWithText = Assert.Single(positionRuns.Where(r => r.Text.Length > 0));
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", runWithText.Text);
|
||||
Assert.True(runWithText.Bold);
|
||||
Assert.False(runWithText.Italic);
|
||||
|
||||
// (3) custom properties ถูก stamp ลงไฟล์ — add-in ใช้ค่านี้ตรวจ tracked document (FR-1/FR-10)
|
||||
Assert.Equal(AddInMetadata.AddInSignatureGuid, TestDocx.ReadCustomProperty(fileBytes, "SavedByAddIn"));
|
||||
Assert.Equal(doc.DocumentId.ToString(), TestDocx.ReadCustomProperty(fileBytes, "DocumentTrackingId"));
|
||||
|
||||
// (4) สถานะใน Postgres: Document ผูก template + v1 + field values ครบ
|
||||
await using var db = fixture.CreateDb();
|
||||
var document = await db.Documents.AsNoTracking()
|
||||
.Include(d => d.Versions)
|
||||
.SingleAsync(d => d.Id == doc.DocumentId);
|
||||
Assert.Equal(templateId, document.TemplateId);
|
||||
Assert.Equal(AddInMetadata.AddInSignatureGuid, document.AddInSignature);
|
||||
Assert.EndsWith(".docx", document.FileName);
|
||||
|
||||
var version = Assert.Single(document.Versions);
|
||||
Assert.Equal(1, version.VersionNumber);
|
||||
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // จาก claim sub เท่านั้น
|
||||
|
||||
var fieldValues = await db.DocumentFieldValues.AsNoTracking()
|
||||
.Where(v => v.DocumentId == doc.DocumentId)
|
||||
.ToDictionaryAsync(v => v.FieldKey);
|
||||
Assert.Equal(4, fieldValues.Count);
|
||||
Assert.Equal("สมหญิง รักงาน", fieldValues["employee_name"].Value);
|
||||
Assert.Equal("75000", fieldValues["salary"].Value);
|
||||
Assert.All(fieldValues.Values, v => Assert.Equal(doc.VersionId, v.SourceVersionId));
|
||||
Assert.All(fieldValues.Values, v => Assert.Equal(fixture.TestUserId, v.UpdatedByUserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_MissingRequiredField_Returns400()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "required ขาด");
|
||||
|
||||
var values = new Dictionary<string, string>(TestFlow.DefaultValues);
|
||||
values.Remove("employee_name"); // required field หาย
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("employee_name", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("salary", "ไม่ใช่เลข")]
|
||||
[InlineData("start_date", "ไม่ใช่วันที่")]
|
||||
public async Task Generate_WrongFieldType_Returns400(string key, string badValue)
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"type ผิด {key}");
|
||||
|
||||
var values = new Dictionary<string, string>(TestFlow.DefaultValues) { [key] = badValue };
|
||||
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_IgnoresUndefinedValues()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "ค่าเกินนิยาม");
|
||||
|
||||
var values = new Dictionary<string, string>(TestFlow.DefaultValues)
|
||||
{
|
||||
["field_ที่ไม่มีใน_template"] = "ส่งเกินมา", // policy FR-11: ignore
|
||||
};
|
||||
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId, values);
|
||||
Assert.Equal(1, doc.VersionNumber);
|
||||
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.DocumentFieldValues.AsNoTracking()
|
||||
.AnyAsync(v => v.DocumentId == doc.DocumentId && v.FieldKey == "field_ที่ไม่มีใน_template"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_UnknownTemplate_Returns404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/documents/generate",
|
||||
new { templateId = Guid.NewGuid(), values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>FR-12 — detail ของเอกสาร: merge นิยาม × ค่าล่าสุด แบบรองรับ orphan ทั้งสองทาง
|
||||
/// (template replace ไฟล์แล้ว: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)</summary>
|
||||
[Fact]
|
||||
public async Task GetDetail_MergesDefsAndValues_BothOrphanWays()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "orphan merge");
|
||||
var doc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
|
||||
// replace ไฟล์ template: เหลือ employee_name + เพิ่ม note → position/salary/start_date กลายเป็นค่า orphan
|
||||
using var replace = await client.PutAsync(
|
||||
$"api/templates/{templateId}/file",
|
||||
new MultipartFormDataContent
|
||||
{
|
||||
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
|
||||
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
|
||||
"file", "replaced.docx" },
|
||||
});
|
||||
replace.EnsureSuccessStatusCode();
|
||||
|
||||
using var detailResponse = await client.GetAsync($"api/documents/{doc.DocumentId}");
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
var detail = await detailResponse.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Equal(templateId, detail!.TemplateId);
|
||||
var byKey = detail.Fields.ToDictionary(f => f.Key);
|
||||
|
||||
// def ใหม่ (note) ยังไม่มีค่า — แสดงเป็นค่าว่าง + ไม่มี source version
|
||||
Assert.Equal("", byKey["note"].Value);
|
||||
Assert.Null(byKey["note"].SourceVersionId);
|
||||
Assert.Equal("note", byKey["note"].Label);
|
||||
|
||||
// def เดิมที่ยังอยู่ (employee_name) — ค่า v1 ยังอยู่ครบ
|
||||
Assert.Equal("สมหญิง รักงาน", byKey["employee_name"].Value);
|
||||
Assert.Equal(doc.VersionId, byKey["employee_name"].SourceVersionId);
|
||||
|
||||
// ค่าเก่าที่ def หายไป (position/salary/start_date) — ต่อท้ายโดยไม่มีนิยาม
|
||||
Assert.Null(byKey["position"].Label);
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", byKey["position"].Value);
|
||||
Assert.Equal("75000", byKey["salary"].Value);
|
||||
Assert.Equal("2026-08-01", byKey["start_date"].Value);
|
||||
}
|
||||
|
||||
/// <summary>FR-12 — รายการเอกสารต่อ template: versionCount/Latest ถูกต้อง เรียงกิจกรรมล่าสุดก่อน</summary>
|
||||
[Fact]
|
||||
public async Task ListDocuments_ShowsVersionCounts_LatestFirst()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "รายการเอกสาร");
|
||||
var firstDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
var secondDoc = await TestFlow.GenerateDocumentAsync(client, templateId);
|
||||
|
||||
// เพิ่ม v2 ให้เอกสารแรก → กิจกรรมล่าสุดเป็นของเอกสารแรก
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{firstDoc.DocumentId}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx("แก้ไข"), "doc.docx", Guid.NewGuid().ToString(),
|
||||
$$"""{"employee_name":"สมหญิง แก้ไขแล้ว","position":"วิศวกรซอฟต์แวร์","salary":"80000","start_date":"2026-08-01"}"""));
|
||||
upload.EnsureSuccessStatusCode();
|
||||
|
||||
using var listResponse = await client.GetAsync($"api/templates/{templateId}/documents");
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
var items = await listResponse.Content.ReadFromJsonAsync<List<TemplateDocItem>>(IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Equal(2, items!.Count);
|
||||
Assert.Equal(firstDoc.DocumentId, items[0].DocumentId); // ล่าสุดก่อน
|
||||
Assert.Equal(2, items[0].VersionCount);
|
||||
Assert.Equal(2, items[0].LatestVersionNumber);
|
||||
Assert.Equal(secondDoc.DocumentId, items[1].DocumentId);
|
||||
Assert.Equal(1, items[1].VersionCount);
|
||||
}
|
||||
|
||||
/// <summary>FR-12 — detail ของเอกสารที่ไม่ผูก template (track เองจาก add-in): Fields ว่าง templateId null</summary>
|
||||
[Fact]
|
||||
public async Task GetDetail_OfStandaloneDocument_HasEmptyFields()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
using var client = fixture.CreateClient();
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "เอกสารอิสระ.docx", Guid.NewGuid().ToString()));
|
||||
upload.EnsureSuccessStatusCode();
|
||||
|
||||
using var response = await client.GetAsync($"api/documents/{documentId}");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var detail = await response.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Null(detail!.TemplateId);
|
||||
Assert.Null(detail.TemplateName);
|
||||
Assert.Empty(detail.Fields);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Testcontainers.Minio;
|
||||
using Testcontainers.PostgreSql;
|
||||
using WordVersioning.Api.Data;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>ชื่อ collection ที่ใช้ fixture เดียวกัน — ทุก test class ต้อง [Collection(Name)]
|
||||
/// เพื่อให้รันตามลำดับ (ไม่ parallel) กับ DB/container ชุดเดียวกัน</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture>
|
||||
{
|
||||
public const string Name = "integration";
|
||||
}
|
||||
|
||||
/// <summary>Foundation ของ integration tests: รัน PostgreSQL + MinIO + Keycloak จริงผ่าน
|
||||
/// Testcontainers (ตามแผน Phase 7) แล้วยิง API ผ่าน WebApplicationFactory ที่ชี้ config ไปที่ container ทั้งหมด
|
||||
///
|
||||
/// - Keycloak: import realm จาก <c>Fixtures/realm-word-versioning-test.json</c> (สำเนาของ dev realm
|
||||
/// ที่เปิด password grant ให้ client word-versioning-web + ยืดอายุ access token — เฉพาะตอน test)
|
||||
/// user ของ test = `testuser` ที่มาพร้อม realm (เหมือน dev) → ขอ token จริงผ่าน password grant
|
||||
/// → backend validate ผ่าน JWKS เต็มขั้นตอน · (ไม่ใช้ master admin API เพราะ master realm
|
||||
/// บังคับ HTTPS บน token endpoint แม้ใน container http)
|
||||
/// - PostgreSQL: run EF migrations จริง
|
||||
/// - MinIO: สร้าง bucket ทั้งสองให้เหมือน init service ของ docker-compose</summary>
|
||||
public sealed class IntegrationTestFixture : IAsyncLifetime
|
||||
{
|
||||
public const string MinioUser = "minioadmin";
|
||||
public const string MinioPassword = "minioadmin123";
|
||||
public const string TestUserName = "testuser";
|
||||
public const string TestUserPassword = "Test1234!";
|
||||
|
||||
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16") // ให้ตรงกับ docker-compose ของ dev
|
||||
.Build();
|
||||
|
||||
private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest")
|
||||
.WithUsername(MinioUser)
|
||||
.WithPassword(MinioPassword)
|
||||
.Build();
|
||||
|
||||
private readonly IContainer _keycloak = new ContainerBuilder("quay.io/keycloak/keycloak:26.2")
|
||||
.WithCommand("start-dev", "--import-realm")
|
||||
.WithPortBinding(8080, assignRandomHostPort: true)
|
||||
// realm สำหรับ test — เหมือน dev realm แต่เปิด password grant (ทำให้ขอ user token ได้โดยไม่ต้องเดิน PKCE)
|
||||
// ⚠️ target ของ overload นี้คือ "directory" (FilePath, DirectoryPath) — ชี้ dir แล้วไฟล์เข้าไป
|
||||
// ในนามเดิม realm-word-versioning-test.json ซึ่ง Keycloak import ทุก *.json ใน dir นี้
|
||||
.WithResourceMapping(
|
||||
new FileInfo(Path.Combine(AppContext.BaseDirectory, "Fixtures", "realm-word-versioning-test.json")),
|
||||
"/opt/keycloak/data/import")
|
||||
.Build();
|
||||
|
||||
private WebApplicationFactory<Program> _factory = null!;
|
||||
private string _accessToken = string.Empty;
|
||||
|
||||
/// <summary>userId จาก claim `sub` ของ token ที่ fixture ถืออยู่ — ใช้เทียบกับ CreatedByUserId (AC-5)</summary>
|
||||
public string TestUserId { get; private set; } = string.Empty;
|
||||
|
||||
public string AccessToken => _accessToken;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// ยก containers ขึ้นพร้อมกัน — Keycloak ช้าสุด (~20-40s) ให้ที่เหลือรันคู่ไปเลย
|
||||
await Task.WhenAll(_postgres.StartAsync(), _minio.StartAsync(), _keycloak.StartAsync());
|
||||
|
||||
var keycloakBase = $"http://{_keycloak.Hostname}:{_keycloak.GetMappedPublicPort(8080)}";
|
||||
var minioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(9000)}";
|
||||
|
||||
// Keycloak ใช้เวลา boot ~20-40s — รอจน realm endpoint ตอบ 200 จริงก่อน
|
||||
// (port เปิดไม่พอ: docker-proxy รับ connection ก่อนแล้วตัดสาย → "response ended prematurely")
|
||||
try
|
||||
{
|
||||
await WaitUntilHttpOk($"{keycloakBase}/realms/word-versioning/.well-known/openid-configuration");
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
// แนบ log ของ container มาด้วย — ไม่งั้นเห็นแค่ timeout ไม่รู้ว่า realm import พังหรือ boot ช้า
|
||||
var (stdout, _) = await _keycloak.GetLogsAsync();
|
||||
var lines = stdout.Split('\n');
|
||||
var tail = string.Join('\n', lines.Length > 40 ? lines[^40..] : lines);
|
||||
throw new TimeoutException($"{ex.Message}\n--- Keycloak logs (last 40 lines) ---\n{tail}");
|
||||
}
|
||||
|
||||
// user มาพร้อม realm (testuser) — ขอ password-grant token แล้วอ่าน `sub` เก็บไว้เทียบกับ CreatedByUserId
|
||||
_accessToken = await RequestUserTokenAsync(keycloakBase);
|
||||
TestUserId = ReadJwtClaim(_accessToken, "sub")!;
|
||||
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
// Production = ไม่อ่าน appsettings.Development.json (DevHttps/cert ฯลฯ) — test ใช้ TestServer ไม่เกี่ยวกับ port จริง
|
||||
builder.UseEnvironment("Production");
|
||||
builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = _postgres.GetConnectionString(),
|
||||
["MinIO:Endpoint"] = minioEndpoint,
|
||||
["MinIO:AccessKey"] = MinioUser,
|
||||
["MinIO:SecretKey"] = MinioPassword,
|
||||
["MinIO:UseSSL"] = "false",
|
||||
["Authentication:Authority"] = $"{keycloakBase}/realms/word-versioning",
|
||||
["Authentication:ValidAudiences:0"] = "word-addin-officejs",
|
||||
["Authentication:ValidAudiences:1"] = "word-versioning-web",
|
||||
["Cors:AllowedOrigins:0"] = "https://localhost:3000",
|
||||
}));
|
||||
});
|
||||
|
||||
// สร้าง schema จริงด้วย EF migrations (ไม่ใช่ EnsureCreated — ต้องผ่าน migration เหมือน production)
|
||||
await using (var db = CreateDb())
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await CreateBucketsAsync(minioEndpoint);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
// null-safe: ถ้า InitializeAsync พังกลางทาง Dispose ต้องไม่พังตาม (กลบ error ตัวจริง)
|
||||
if (_factory is not null)
|
||||
await _factory.DisposeAsync();
|
||||
await Task.WhenAll(
|
||||
_postgres.DisposeAsync().AsTask(),
|
||||
_minio.DisposeAsync().AsTask(),
|
||||
_keycloak.DisposeAsync().AsTask());
|
||||
}
|
||||
|
||||
/// <summary>HttpClient ที่แนบ Bearer token ของ test user ไว้แล้ว</summary>
|
||||
public HttpClient CreateClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>HttpClient ไม่แนบ token — ใช้ตรวจว่าได้ 401 (AC-3)</summary>
|
||||
public HttpClient CreateAnonymousClient() => _factory.CreateClient();
|
||||
|
||||
/// <summary>DbContext ตรงไปที่ test database — สำหรับ assert สถานะใน Postgres จริง</summary>
|
||||
public AppDbContext CreateDb() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseNpgsql(_postgres.GetConnectionString())
|
||||
.Options);
|
||||
|
||||
/// <summary>user token จริงผ่าน password grant (client word-versioning-web ของ realm test เปิดให้ตอน test เท่านั้น)
|
||||
/// — token มี aud = word-versioning-web จาก audience mapper ของ realm ตามที่ backend validate
|
||||
/// (เลี่ยง PKCE dance ของ production flow — ตัว validate ฝั่ง backend คือตัวเดิมทดสอบเต็มรูปแบบ)</summary>
|
||||
private static async Task<string> RequestUserTokenAsync(string keycloakBase)
|
||||
{
|
||||
using var http = new HttpClient { BaseAddress = new Uri(keycloakBase) };
|
||||
using var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["client_id"] = "word-versioning-web",
|
||||
["username"] = TestUserName,
|
||||
["password"] = TestUserPassword,
|
||||
});
|
||||
using var response = await http.PostAsync("/realms/word-versioning/protocol/openid-connect/token", form);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Json);
|
||||
return payload.GetProperty("access_token").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>อ่าน claim จาก JWT payload (ไม่ต้อง validate signature — ได้มาจาก Keycloak โดยตรง)</summary>
|
||||
private static string? ReadJwtClaim(string jwt, string claim)
|
||||
{
|
||||
var payload = jwt.Split('.')[1];
|
||||
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
|
||||
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
|
||||
return doc.RootElement.TryGetProperty(claim, out var value) ? value.GetString() : null;
|
||||
}
|
||||
|
||||
/// <summary>poll URL จนตอบ 2xx — Keycloak readiness probe แบบไม่ผูกกับ wait strategy API ของ Testcontainers</summary>
|
||||
private static async Task WaitUntilHttpOk(string url, TimeSpan? timeout = null)
|
||||
{
|
||||
timeout ??= TimeSpan.FromMinutes(2);
|
||||
using var http = new HttpClient();
|
||||
var deadline = DateTime.UtcNow + timeout.Value;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// ยังไม่พร้อม — พยายามต่อ
|
||||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
throw new TimeoutException($"endpoint ไม่พร้อมภายใน {timeout}: {url}");
|
||||
}
|
||||
|
||||
/// <summary>สร้าง bucket ทั้งสองแบบ private — ทำสิ่งเดียวกับ init service (mc) ของ docker-compose
|
||||
/// พร้อม retry เผื่อ MinIO เพิ่งขึ้นและยังไม่รับ request</summary>
|
||||
private async Task CreateBucketsAsync(string minioEndpoint)
|
||||
{
|
||||
var config = new AmazonS3Config { ServiceURL = $"http://{minioEndpoint}", ForcePathStyle = true };
|
||||
using var s3 = new AmazonS3Client(
|
||||
new BasicAWSCredentials(MinioUser, MinioPassword), config);
|
||||
|
||||
foreach (var bucket in new[] { "docx-versions", "docx-templates" })
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
// AWSSDK v4 ไม่มี DoesS3BucketExist — ยิงเลยแล้วถือว่าจบ (bucket ซ้ำ = ถือว่ามีอยู่แล้ว)
|
||||
try
|
||||
{
|
||||
await s3.PutBucketAsync(bucket);
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.ErrorCode == "BucketAlreadyOwnedByYou")
|
||||
{
|
||||
}
|
||||
break;
|
||||
}
|
||||
catch (AmazonS3Exception) when (attempt < 10)
|
||||
{
|
||||
await Task.Delay(500); // MinIO เพิ่งขึ้น — รอแล้วลองใหม่
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
236
backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs
Normal file
236
backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-9 — template CRUD + auto-detect SDT tags ตอน upload/replace ไฟล์ (AC-11 ส่วน backend)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class TemplateTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
/// <summary>สร้าง template จาก BuildTemplateDocx() — คืน (id, fields ที่ detect ได้) — ใช้ร่วมกับ test อื่น</summary>
|
||||
public static async Task<(Guid TemplateId, List<TemplateFieldItem> Fields)> CreateDefaultTemplateAsync(
|
||||
HttpClient client, string name)
|
||||
{
|
||||
using var response = await client.PostAsync(
|
||||
"api/templates", ApiExtensions.TemplateForm(TestDocx.BuildTemplateDocx(), name));
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json);
|
||||
return (result!.Id, result.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DetectsAllSdtTags_WithDefaults()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"สัญญา {Guid.NewGuid():N}");
|
||||
|
||||
// 5 tags ในไฟล์ (employee_name ซ้ำ 2 จุด → dedup เหลือ 4) ตามลำดับที่ปรากฏ
|
||||
Assert.Equal(
|
||||
["employee_name", "position", "salary", "start_date"],
|
||||
fields.Select(f => f.Key).ToList());
|
||||
|
||||
// defaults: label = tag, type = text, required = false (FR-9)
|
||||
Assert.All(fields, f =>
|
||||
{
|
||||
Assert.Equal(f.Key, f.Label);
|
||||
Assert.Equal("text", f.Type);
|
||||
Assert.False(f.IsRequired);
|
||||
});
|
||||
|
||||
// GET detail คืนนิยามชุดเดิม
|
||||
using var detail = await client.GetAsync($"api/templates/{templateId}");
|
||||
var detailBody = await detail.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
|
||||
Assert.Equal(4, detailBody!.Fields.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RejectsNonDocxFile()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent("ไม่ใช่ template"), "name");
|
||||
var file = new ByteArrayContent("plain"u8.ToArray());
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
|
||||
form.Add(file, "file", "template.txt");
|
||||
|
||||
using var response = await client.PostAsync("api/templates", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RejectsMissingName()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(TestDocx.BuildPlainDocx());
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType);
|
||||
form.Add(file, "file", "template.docx"); // ไม่ส่ง name
|
||||
|
||||
using var response = await client.PostAsync("api/templates", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_SavesFieldDefinitions_AndMetadata()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"แก้นิยาม {Guid.NewGuid():N}");
|
||||
|
||||
var updated = new
|
||||
{
|
||||
description = "สัญญาจ้างฉบับมาตรฐาน",
|
||||
fields = fields.Select((f, i) => new
|
||||
{
|
||||
key = f.Key,
|
||||
label = $"ป้ายกำกับ {f.Key}",
|
||||
type = f.Key == "salary" ? "number" : f.Key == "start_date" ? "date" : "text",
|
||||
isRequired = f.Key == "employee_name",
|
||||
sortOrder = fields.Count - i, // กลับลำดับ
|
||||
}),
|
||||
};
|
||||
|
||||
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
var body = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Equal("สัญญาจ้างฉบับมาตรฐาน", body!.Description);
|
||||
Assert.Equal("ป้ายกำกับ employee_name", body.Fields[3].Label); // กลับลำดับแล้ว employee_name ท้ายสุด
|
||||
Assert.Equal("number", body.Fields.First(f => f.Key == "salary").Type);
|
||||
Assert.Equal("date", body.Fields.First(f => f.Key == "start_date").Type);
|
||||
Assert.True(body.Fields.First(f => f.Key == "employee_name").IsRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_RejectsUnknownFieldKey()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"key นอกไฟล์ {Guid.NewGuid():N}");
|
||||
|
||||
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", new
|
||||
{
|
||||
fields = new[] { new { key = "not_in_file", label = "ไม่มีในไฟล์", type = "text", isRequired = false, sortOrder = 0 } },
|
||||
}, IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode); // เพิ่ม/ลบ key ต้องผ่าน replace ไฟล์เท่านั้น
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_RejectsInvalidFieldType()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, fields) = await CreateDefaultTemplateAsync(client, $"type ผิด {Guid.NewGuid():N}");
|
||||
|
||||
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", new
|
||||
{
|
||||
fields = fields.Select(f => new { key = f.Key, label = f.Key, type = "datetime", isRequired = false, sortOrder = f.SortOrder }),
|
||||
}, IntegrationTestFixture.Json);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceFile_DetectsAddedAndRemovedTags()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"replace {Guid.NewGuid():N}");
|
||||
|
||||
using var put = await client.PutAsync(
|
||||
$"api/templates/{templateId}/file",
|
||||
new MultipartFormDataContent
|
||||
{
|
||||
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
|
||||
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
|
||||
"file", "replaced.docx" },
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
var body = await put.Content.ReadFromJsonAsync<ReplaceFileResult>(IntegrationTestFixture.Json);
|
||||
|
||||
// เดิม: employee_name, position, salary, start_date → ใหม่: employee_name, note
|
||||
Assert.Equal(["note"], body!.AddedTags);
|
||||
Assert.Equal(
|
||||
new[] { "position", "salary", "start_date" }.Order(),
|
||||
body.RemovedTags.Order());
|
||||
Assert.Equal(["employee_name", "note"], body.Fields.Select(f => f.Key).ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_HidesFromDefaultList_ButVisibleWithIncludeInactive()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"deactivate {Guid.NewGuid():N}");
|
||||
|
||||
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
|
||||
|
||||
using var activeList = await client.GetAsync("api/templates");
|
||||
var active = await activeList.Content.ReadFromJsonAsync<List<TemplateListItemResult>>(IntegrationTestFixture.Json);
|
||||
Assert.DoesNotContain(active!, t => t.Id == templateId);
|
||||
|
||||
using var allList = await client.GetAsync("api/templates?includeInactive=true");
|
||||
var all = await allList.Content.ReadFromJsonAsync<List<TemplateListItemResult>>(IntegrationTestFixture.Json);
|
||||
Assert.Contains(all!, t => t.Id == templateId && !t.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_ThenGenerate_Returns400_ButDocumentsOfTemplateStillAccessible()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await CreateDefaultTemplateAsync(client, $"deactivate+generate {Guid.NewGuid():N}");
|
||||
|
||||
// สร้างเอกสาร 1 ฉบับก่อน deactivate
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["employee_name"] = "สมชาย ใจดี",
|
||||
["position"] = "โปรแกรมเมอร์",
|
||||
["salary"] = "50000",
|
||||
["start_date"] = "2026-08-20",
|
||||
};
|
||||
using var generate = await client.PostAsJsonAsync("api/documents/generate",
|
||||
new { templateId, values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.Created, generate.StatusCode);
|
||||
var doc = await generate.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json);
|
||||
|
||||
using var deactivate = await client.DeleteAsync($"api/templates/{templateId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivate.StatusCode);
|
||||
|
||||
// สร้างเอกสารใหม่ไม่ได้ (FR-9 policy)
|
||||
using var generateAgain = await client.PostAsJsonAsync("api/documents/generate",
|
||||
new { templateId, values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, generateAgain.StatusCode);
|
||||
|
||||
// แต่เอกสารเก่ายังเข้าถึงได้ครบ: list + detail + history + download (FR-9 + FR-12)
|
||||
using var list = await client.GetAsync($"api/templates/{templateId}/documents");
|
||||
Assert.Equal(HttpStatusCode.OK, list.StatusCode);
|
||||
var documents = await list.Content.ReadFromJsonAsync<List<TemplateDocItem>>(IntegrationTestFixture.Json);
|
||||
var listItem = documents!.Single();
|
||||
Assert.Equal(doc!.DocumentId, listItem.DocumentId);
|
||||
Assert.Equal(1, listItem.VersionCount);
|
||||
|
||||
using var detail = await client.GetAsync($"api/documents/{doc!.DocumentId}");
|
||||
Assert.Equal(HttpStatusCode.OK, detail.StatusCode);
|
||||
Assert.True((await detail.Content.ReadFromJsonAsync<DocDetail>(IntegrationTestFixture.Json))!.TemplateIsActive is false);
|
||||
|
||||
using var history = await client.GetAsync($"api/documents/{doc.DocumentId}/versions");
|
||||
Assert.Equal(HttpStatusCode.OK, history.StatusCode);
|
||||
|
||||
using var download = await client.GetAsync($"api/documents/{doc.DocumentId}/versions/{doc.VersionId}/download");
|
||||
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_UnknownTemplate_Returns404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.GetAsync($"api/templates/{Guid.NewGuid()}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDocuments_UnknownTemplate_Returns404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.GetAsync($"api/templates/{Guid.NewGuid()}/documents");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
146
backend/WordVersioning.Api.IntegrationTests/TestDocx.cs
Normal file
146
backend/WordVersioning.Api.IntegrationTests/TestDocx.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using System.Text;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>สร้าง/อ่านไฟล์ .docx สำหรับ test — ไม่ต้องพึ่ง binary file ใน repo
|
||||
/// สร้างได้ทั้ง template ที่มี content controls (SDT) หลายรูปแบบและไฟล์เปล่าไม่มี SDT</summary>
|
||||
public static class TestDocx
|
||||
{
|
||||
public const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
/// <summary>สร้าง template มาตรฐาน: SDT แบบ run เดียว / multi-run คนละ formatting / block-level
|
||||
/// + tag ซ้ำอีกจุด (detect ต้อง dedup) — ครอบคลุมรูปแบบเดียวกับที่ใช้ตรวจ Phase 3 ตอน dev
|
||||
///
|
||||
/// ⚠️ run-level SDT (SdtRun) ต้องอยู่ "ใน" paragraph เท่านั้น (ตาม OOXML schema เหมือนที่ Word author ให้)
|
||||
/// — วางเป็นลูกตรงของ Body แล้วตอน reload จะกลายเป็น SdtBlock + OpenXmlUnknownElement ทั้งชุด
|
||||
/// ทำให้ generate เข้า branch ผิด (append แทนที่จะ replace)</summary>
|
||||
public static byte[] BuildTemplateDocx()
|
||||
{
|
||||
var buffer = new MemoryStream();
|
||||
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
|
||||
{
|
||||
var main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document(new Body(
|
||||
new Paragraph(new Run(new Text("สัญญาจ้างงานฉบับทดสอบ"))),
|
||||
|
||||
// SDT run-level เดี่ยว — รูปแบบที่พบบ่อยสุด
|
||||
new Paragraph(SdtRun("employee_name", new Run(
|
||||
new RunProperties(new Bold()),
|
||||
new Text("[ชื่อพนักงาน]")))),
|
||||
|
||||
// SDT multi-run คนละ formatting — generate ต้องเขียนค่าที่ run แรก (รักษา bold) และลบ text ของ run หลัง
|
||||
new Paragraph(SdtRun("position",
|
||||
new Run(
|
||||
new RunProperties(new Bold()),
|
||||
new Text("[ตำแหน่ง")),
|
||||
new Run(
|
||||
new RunProperties(new Italic()),
|
||||
new Text("หน้าที่]")))),
|
||||
|
||||
// SDT block-level (ทั้งย่อหน้า) — ตัวนี้เท่านั้นที่เป็นลูกตรงของ Body ได้
|
||||
new SdtBlock(
|
||||
new SdtProperties(new Tag { Val = "salary" }),
|
||||
new SdtContentBlock(new Paragraph(new Run(new Text("[เงินเดือน]"))))),
|
||||
|
||||
new Paragraph(SdtRun("start_date", new Run(new Text("[วันเริ่มงาน]")))),
|
||||
|
||||
// tag ซ้ำ (อีกจุดของเอกสาร) — detect ต้องได้ครั้งเดียว
|
||||
new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อพนักงานอีกครั้ง]")))),
|
||||
|
||||
new Paragraph(new Run(new Text("จบเอกสาร")))));
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>template ตัวแปร: มี employee_name + position เท่านั้น — ใช้ตอน replace ไฟล์ (tag เพิ่ม/หาย)</summary>
|
||||
public static byte[] BuildReplacementTemplateDocx()
|
||||
{
|
||||
var buffer = new MemoryStream();
|
||||
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
|
||||
{
|
||||
var main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document(new Body(
|
||||
new Paragraph(new Run(new Text("สัญญาฉบับแก้ไข"))),
|
||||
new Paragraph(SdtRun("employee_name", new Run(new Text("[ชื่อ]")))),
|
||||
new Paragraph(SdtRun("note", new Run(new Text("[หมายเหตุ]"))))));
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>ไฟล์ .docx ไม่มี content control เลย — จำลองเอกสารธรรมดาที่ track เองผ่าน add-in</summary>
|
||||
public static byte[] BuildPlainDocx(string text = "เอกสารทั่วไปไม่มี content control")
|
||||
{
|
||||
var buffer = new MemoryStream();
|
||||
using (var doc = WordprocessingDocument.Create(buffer, WordprocessingDocumentType.Document))
|
||||
{
|
||||
var main = doc.AddMainDocumentPart();
|
||||
main.Document = new Document(new Body(new Paragraph(new Run(new Text(text)))));
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>อ่านข้อความรวมของ SDT ตาม tag (คืน "" เมื่อไม่มี tag นั้นในไฟล์) — ตรวจผลลัพธ์ generate</summary>
|
||||
public static string ReadSdtText(byte[] docx, string tag)
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
||||
var main = doc.MainDocumentPart!;
|
||||
foreach (var root in new OpenXmlPartRootElement?[] { main.Document }
|
||||
.Concat(main.HeaderParts.Select(h => (OpenXmlPartRootElement?)h.Header))
|
||||
.Concat(main.FooterParts.Select(f => (OpenXmlPartRootElement?)f.Footer)))
|
||||
{
|
||||
if (root is null) continue;
|
||||
foreach (var sdtTag in root.Descendants<Tag>())
|
||||
{
|
||||
if (sdtTag.Val?.Value != tag) continue;
|
||||
var sdt = sdtTag.Parent?.Parent;
|
||||
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
|
||||
if (content is null) return "";
|
||||
var sb = new StringBuilder();
|
||||
foreach (var text in content.Descendants<Text>())
|
||||
sb.Append(text.Text);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>อ่านข้อความ+formatting ของแต่ละ run ใน SDT (ตาม tag) — ใช้ตรวจว่า generate
|
||||
/// รวม multi-run เป็น run เดียวแล้วยังรักษา run properties (bold/italic) ของ run แรกไว้</summary>
|
||||
public static List<(string Text, bool Bold, bool Italic)> ReadSdtRuns(byte[] docx, string tag)
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
||||
var root = doc.MainDocumentPart!.Document!;
|
||||
foreach (var sdtTag in root.Descendants<Tag>())
|
||||
{
|
||||
if (sdtTag.Val?.Value != tag) continue;
|
||||
var sdt = sdtTag.Parent?.Parent;
|
||||
var content = sdt?.ChildElements.FirstOrDefault(c => c.LocalName == "sdtContent");
|
||||
return content?.Descendants<Run>()
|
||||
.Select(run => (
|
||||
string.Concat(run.Descendants<Text>().Select(t => t.Text)),
|
||||
run.RunProperties?.Bold is not null,
|
||||
run.RunProperties?.Italic is not null))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>อ่าน custom property (VTLPWSTR) — null เมื่อไม่มี property นั้น
|
||||
/// ใช้ตรวจ SavedByAddIn / DocumentTrackingId ที่ backend stamp ลงไฟล์ (FR-10)</summary>
|
||||
public static string? ReadCustomProperty(byte[] docx, string name)
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(new MemoryStream(docx), false);
|
||||
var props = doc.CustomFilePropertiesPart?.Properties;
|
||||
return props?.Elements<DocumentFormat.OpenXml.CustomProperties.CustomDocumentProperty>()
|
||||
.FirstOrDefault(p => p.Name?.Value == name)
|
||||
?.VTLPWSTR?.Text;
|
||||
}
|
||||
|
||||
private static SdtRun SdtRun(string tag, params Run[] runs) => new(
|
||||
new SdtProperties(new Tag { Val = tag }),
|
||||
new SdtContentRun(runs));
|
||||
}
|
||||
64
backend/WordVersioning.Api.IntegrationTests/TestDtos.cs
Normal file
64
backend/WordVersioning.Api.IntegrationTests/TestDtos.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>DTO ของ response ฝั่ง test — mirror ของ API (deserialize ด้วย camelCase เหมือนที่ API คืน)</summary>
|
||||
public record UploadVersionResult(Guid VersionId, int VersionNumber);
|
||||
|
||||
public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber, string DownloadUrl);
|
||||
|
||||
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields);
|
||||
|
||||
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder);
|
||||
|
||||
public record CreateTemplateResult(Guid Id, List<TemplateFieldItem> Fields);
|
||||
|
||||
public record TemplateDetailResult(Guid Id, string Name, string? Description, bool IsActive, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, List<TemplateFieldItem> Fields);
|
||||
|
||||
public record ReplaceFileResult(Guid Id, List<TemplateFieldItem> Fields, List<string> AddedTags, List<string> RemovedTags);
|
||||
|
||||
public record TemplateListItemResult(Guid Id, string Name, string? Description, bool IsActive, int FieldCount, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record TemplateDocItem(Guid DocumentId, string FileName, DateTimeOffset CreatedAt, int VersionCount, int LatestVersionNumber, DateTimeOffset? LatestVersionAt);
|
||||
|
||||
public record DocFieldItem(string Key, string? Label, string? Type, bool? IsRequired, string Value, Guid? SourceVersionId, string? UpdatedByUserId, DateTimeOffset? UpdatedAt);
|
||||
|
||||
public record DocDetail(Guid Id, string FileName, DateTimeOffset CreatedAt, Guid? TemplateId, string? TemplateName, bool TemplateIsActive, List<DocFieldItem> Fields);
|
||||
|
||||
public static class ApiExtensions
|
||||
{
|
||||
/// <summary>multipart form ของ POST versions: file + clientVersionId + fields (option)</summary>
|
||||
public static MultipartFormDataContent VersionForm(
|
||||
byte[] docx, string fileName, string clientVersionId, string? fieldsJson = null)
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(docx);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(TestDocx.DocxContentType);
|
||||
form.Add(file, "file", fileName);
|
||||
form.Add(new StringContent(clientVersionId), "clientVersionId");
|
||||
if (fieldsJson is not null)
|
||||
form.Add(new StringContent(fieldsJson), "fields");
|
||||
return form;
|
||||
}
|
||||
|
||||
/// <summary>multipart form ของ POST templates / PUT file: name + description (option) + file</summary>
|
||||
public static MultipartFormDataContent TemplateForm(
|
||||
byte[] docx, string name, string? description = null, string fileName = "template.docx")
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(name), "name");
|
||||
if (description is not null)
|
||||
form.Add(new StringContent(description), "description");
|
||||
var file = new ByteArrayContent(docx);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue(TestDocx.DocxContentType);
|
||||
form.Add(file, "file", fileName);
|
||||
return form;
|
||||
}
|
||||
|
||||
public static async Task<T?> ReadJsonAsync<T>(this HttpResponseMessage response) =>
|
||||
response.Content.Headers.ContentType is null
|
||||
? default
|
||||
: await response.Content.ReadFromJsonAsync<T>(IntegrationTestFixture.Json);
|
||||
}
|
||||
74
backend/WordVersioning.Api.IntegrationTests/TestFlow.cs
Normal file
74
backend/WordVersioning.Api.IntegrationTests/TestFlow.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>setup ที่ใช้ร่วมกันหลาย test class: สร้าง template + ตั้ง field definitions ครบชุด
|
||||
/// (employee_name = required text, position = text, salary = number, start_date = date)</summary>
|
||||
public static class TestFlow
|
||||
{
|
||||
public static readonly Dictionary<string, string> DefaultValues = new()
|
||||
{
|
||||
["employee_name"] = "สมหญิง รักงาน",
|
||||
["position"] = "วิศวกรซอฟต์แวร์",
|
||||
["salary"] = "75000",
|
||||
["start_date"] = "2026-08-01",
|
||||
};
|
||||
|
||||
public static async Task<(Guid TemplateId, List<TemplateFieldItem> Fields)> CreateConfiguredTemplateAsync(
|
||||
HttpClient client, string baseName)
|
||||
{
|
||||
// create (detect defaults)
|
||||
using var create = await client.PostAsync(
|
||||
"api/templates", ApiExtensions.TemplateForm(TestDocx.BuildTemplateDocx(), $"{baseName} {Guid.NewGuid():N}"));
|
||||
create.EnsureSuccessStatusCode();
|
||||
var created = await create.Content.ReadFromJsonAsync<CreateTemplateResult>(IntegrationTestFixture.Json);
|
||||
|
||||
// ตั้ง label/type/required ให้ครบทุกรูปแบบ (มี required, number, date)
|
||||
using var put = await client.PutAsJsonAsync($"api/templates/{created!.Id}", new
|
||||
{
|
||||
fields = created.Fields.Select(f => new
|
||||
{
|
||||
key = f.Key,
|
||||
label = LabelOf(f.Key),
|
||||
type = TypeOf(f.Key),
|
||||
isRequired = f.Key == "employee_name",
|
||||
sortOrder = f.SortOrder,
|
||||
}),
|
||||
}, IntegrationTestFixture.Json);
|
||||
put.EnsureSuccessStatusCode();
|
||||
var detail = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
|
||||
|
||||
return (created.Id, detail!.Fields);
|
||||
}
|
||||
|
||||
/// <summary>generate เอกสาร v1 จาก template ที่ตั้งค่าแล้ว — คืน response ของ generate</summary>
|
||||
public static Task<GenerateResult> GenerateDocumentAsync(HttpClient client, Guid templateId) =>
|
||||
GenerateDocumentAsync(client, templateId, DefaultValues);
|
||||
|
||||
/// <summary>generate พร้อมค่าที่กำหนดเอง (ทดสอบ validation หรือค่าเกินนิยาม)</summary>
|
||||
public static async Task<GenerateResult> GenerateDocumentAsync(
|
||||
HttpClient client, Guid templateId, Dictionary<string, string> values)
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
"api/documents/generate", new { templateId, values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<GenerateResult>(IntegrationTestFixture.Json))!;
|
||||
}
|
||||
|
||||
public static string LabelOf(string key) => key switch
|
||||
{
|
||||
"employee_name" => "ชื่อพนักงาน",
|
||||
"position" => "ตำแหน่ง",
|
||||
"salary" => "เงินเดือน",
|
||||
"start_date" => "วันเริ่มงาน",
|
||||
_ => key,
|
||||
};
|
||||
|
||||
public static string TypeOf(string key) => key switch
|
||||
{
|
||||
"salary" => "number",
|
||||
"start_date" => "date",
|
||||
_ => "text",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-5 (upload), FR-6 (history), FR-7 (download) — ผ่าน HTTP จริง + ตรวจสถานะใน Postgres ตรงๆ</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class UploadVersionTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task FirstUpload_CreatesDocumentAndVersionRow_FromJwtClaims()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
var clientVersionId = Guid.NewGuid();
|
||||
var bytes = TestDocx.BuildPlainDocx("version 1 content");
|
||||
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(bytes, "เอกสารทดสอบ.docx", clientVersionId.ToString()));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
Assert.Equal(1, result!.VersionNumber);
|
||||
Assert.NotEqual(Guid.Empty, result.VersionId);
|
||||
|
||||
// ตรวจ DB จริง: Document auto-create + ค่า tracking จาก JWT claims (AC-5)
|
||||
await using var db = fixture.CreateDb();
|
||||
var doc = await db.Documents.AsNoTracking().SingleAsync(d => d.Id == documentId);
|
||||
Assert.Equal("เอกสารทดสอบ.docx", doc.FileName);
|
||||
Assert.Null(doc.TemplateId); // track เองจาก add-in — ไม่ผูก template
|
||||
|
||||
var version = await db.DocumentVersions.AsNoTracking().SingleAsync(v => v.DocumentId == documentId);
|
||||
Assert.Equal(1, version.VersionNumber);
|
||||
Assert.Equal(fixture.TestUserId, version.CreatedByUserId); // sub จาก token เท่านั้น
|
||||
Assert.Equal(Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), version.ContentHash);
|
||||
Assert.Equal($"{documentId}/v0001_{clientVersionId}.docx", version.ObjectKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondUpload_IncrementsVersionNumber_AndHistorySortsNewestFirst()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var v1 = TestDocx.BuildPlainDocx("v1");
|
||||
using var first = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(v1, "doc.docx", Guid.NewGuid().ToString()));
|
||||
var firstResult = await first.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
|
||||
var v2 = TestDocx.BuildPlainDocx("v2 — แก้ไขแล้ว");
|
||||
using var second = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(v2, "doc.docx", Guid.NewGuid().ToString()));
|
||||
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
|
||||
var secondResult = await second.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
Assert.Equal(2, secondResult!.VersionNumber);
|
||||
|
||||
// FR-6: เรียงใหม่ → เก่า
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
|
||||
Assert.Equal(2, items!.Count);
|
||||
Assert.Equal(secondResult.VersionId, items[0].VersionId);
|
||||
Assert.Equal(2, items[0].VersionNumber);
|
||||
Assert.Equal(firstResult!.VersionId, items[1].VersionId);
|
||||
Assert.Equal(1, items[1].VersionNumber);
|
||||
Assert.All(items, i => Assert.Equal(fixture.TestUserId, i.CreatedByUserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ReturnsExactUploadedBytes_WithDocxContentType()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
var bytes = TestDocx.BuildPlainDocx("ไบต์ต้องตรงกันตอนดาวน์โหลด (AC-8 ส่วน backend)");
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(bytes, "roundtrip.docx", Guid.NewGuid().ToString()));
|
||||
var uploaded = await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json);
|
||||
|
||||
using var download = await client.GetAsync(
|
||||
$"api/documents/{documentId}/versions/{uploaded!.VersionId}/download");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, download.StatusCode);
|
||||
Assert.Equal(TestDocx.DocxContentType, download.Content.Headers.ContentType!.MediaType);
|
||||
var downloaded = await download.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(bytes, downloaded); // byte-for-byte ผ่าน MinIO ไป-กลับ
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_UnknownVersion_Returns404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.GetAsync(
|
||||
$"api/documents/{Guid.NewGuid()}/versions/{Guid.NewGuid()}/download");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVersions_UnknownDocument_Returns404()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.GetAsync($"api/documents/{Guid.NewGuid()}/versions");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_NonDocxFile_Returns400()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
using var client = fixture.CreateClient();
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent("นี่ไม่ใช่ word"u8.ToArray());
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
|
||||
form.Add(file, "file", "notes.txt"); // ชื่อไม่ลงท้าย .docx
|
||||
form.Add(new StringContent(Guid.NewGuid().ToString()), "clientVersionId");
|
||||
|
||||
using var response = await client.PostAsync($"api/documents/{documentId}/versions", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
await using var db = fixture.CreateDb();
|
||||
Assert.False(await db.Documents.AsNoTracking().AnyAsync(d => d.Id == documentId)); // ไม่มี side effect
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_InvalidClientVersionId_Returns400()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
$"api/documents/{Guid.NewGuid()}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", "ไม่ใช่ GUID"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_InvalidFieldsJson_Returns400()
|
||||
{
|
||||
using var client = fixture.CreateClient();
|
||||
using var response = await client.PostAsync(
|
||||
$"api/documents/{Guid.NewGuid()}/versions",
|
||||
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(), "not json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>เอกสารไม่ผูก template → snapshot เก็บ map ดิบตามที่ส่งมา (behavior ของ Phase 2)</summary>
|
||||
[Fact]
|
||||
public async Task Upload_WithoutTemplate_StoresRawFieldsMapInSnapshot()
|
||||
{
|
||||
var documentId = Guid.NewGuid();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
using var upload = await client.PostAsync(
|
||||
$"api/documents/{documentId}/versions",
|
||||
ApiExtensions.VersionForm(
|
||||
TestDocx.BuildPlainDocx(), "doc.docx", Guid.NewGuid().ToString(),
|
||||
"""{"custom_tag":"ค่าที่ส่งมา"}"""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, upload.StatusCode);
|
||||
using var history = await client.GetAsync($"api/documents/{documentId}/versions");
|
||||
var items = await history.Content.ReadFromJsonAsync<List<VersionItem>>(IntegrationTestFixture.Json);
|
||||
|
||||
var fields = items!.Single().Fields!.Value;
|
||||
Assert.Equal(JsonValueKind.Object, fields.ValueKind);
|
||||
Assert.Equal("ค่าที่ส่งมา", fields.GetProperty("custom_tag").GetString());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Testcontainers.Minio" Version="4.14.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- realm สำหรับ import เข้า Keycloak container — อ่านตอน runtime ที่ AppContext.BaseDirectory -->
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WordVersioning.Api\WordVersioning.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -123,3 +123,6 @@ static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string ke
|
|||
// macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
|
||||
return new X509Certificate2(paired.Export(X509ContentType.Pfx));
|
||||
}
|
||||
|
||||
/// <summary>ให้ WebApplicationFactory<Program> ของ integration tests เข้าถึง top-level Program ได้</summary>
|
||||
public partial class Program;
|
||||
|
|
|
|||
4
backend/WordVersioning.slnx
Normal file
4
backend/WordVersioning.slnx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<Solution>
|
||||
<Project Path="WordVersioning.Api.IntegrationTests/WordVersioning.Api.IntegrationTests.csproj" />
|
||||
<Project Path="WordVersioning.Api/WordVersioning.Api.csproj" />
|
||||
</Solution>
|
||||
92
docs/acceptance-review.md
Normal file
92
docs/acceptance-review.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# ตรวจ Acceptance Criteria 1–15 (Phase 8)
|
||||
|
||||
> วันที่ตรวจ: 2026-08-20 · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
|
||||
>
|
||||
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
|
||||
|
||||
สรุปรวม: **AC ทั้ง 15 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร
|
||||
|
||||
## ผลทีละข้อ
|
||||
|
||||
### AC-1 — Sideload บน Word Desktop และ Word Online ได้ทั้งคู่ ไม่มี error ตอนโหลด task pane — 🔶
|
||||
- 🖐️ **Word for Mac (Desktop): ผ่าน** — sideload ผ่าน `npm start` (manifest symlink) ตรวจตอน Phase 5 พร้อมแก้ปัญหาตกมุม 4 รอบ (dialog https / slice format / mixed content / blob download)
|
||||
- ✅ อัตโนมัติ: `npm run build` + `npm run lint` + `npm run validate` (manifest) ผ่านทุกครั้ง
|
||||
- 🔶 **Word Online + Word for Windows: ยังไม่ได้ทดสอบ** (ต้องมีเครื่อง/บัญชีทดสอบ)
|
||||
|
||||
### AC-2 — เปิดครั้งแรกยังไม่ login → Sign in → dialog → login สำเร็จ dialog ปิดเอง ได้ token — ✅ (automated) + 🖐️ (Mac)
|
||||
- ✅ `authService.test.ts` (14 tests): PKCE pair format, state validation, token exchange, refresh, dialog message flow
|
||||
- 🖐️ ทดสอบจริงบน Word for Mac ผ่าน (Keycloak https:8443 + Office Dialog API)
|
||||
|
||||
### AC-3 — เรียก backend โดยไม่แนบ token → 401 — ✅
|
||||
- `AuthTests` (xUnit + Keycloak จริง): ครบทุก endpoint ทั้ง GET/POST + กรณี token ขยะ + กรณี token จริงได้ 200
|
||||
|
||||
### AC-4 — ไฟล์ใหม่ไม่เคย track → ปุ่ม "Start Versioning This Document" — ✅
|
||||
- `metadataService.test.ts`: ตรวจ `SavedByAddIn` เทียบ ADDIN_SIGNATURE_GUID → state `untracked` (App state machine)
|
||||
- 🖐️ ยืนยันแล้วบน Word for Mac ตอน Phase 5
|
||||
|
||||
### AC-5 — Start Versioning → stamp metadata, สร้าง Document, v1, `CreatedByUserId` ตรง `sub` ใน JWT — ✅
|
||||
- `UploadVersionTests.FirstUpload_CreatesDocumentAndVersionRow_FromJwtClaims`: token จริงจาก Keycloak → ตรวจ row ใน Postgres ตรงๆ ว่า `CreatedByUserId == sub` + SHA256 + object key format ถูกต้อง
|
||||
|
||||
### AC-6 — ปิดแล้วเปิดไฟล์เดิม → ตรวจพบ tracked + โหลด history ถูก — ✅
|
||||
- `UploadVersionTests.SecondUpload_...` + `GetVersions` ordering (ใหม่ → เก่า) + snapshot ต่อ version
|
||||
- 🖐️ ยืนยันบน Mac แล้ว (round 3 ของ Phase 5)
|
||||
|
||||
### AC-7 — แก้ไขแล้ว Save & Version → version ใหม่ + `LastVersionId`/`LastVersionTimestamp` อัปเดต — ✅
|
||||
- `versionFlow.test.ts` (7 ขั้นตาม spec ครบ) + metadataService stamp tests + `SecondUpload_IncrementsVersionNumber`
|
||||
- หมายเหตุจาก Phase 5: `LastVersionId` ในไฟล์ = `clientVersionId` (ต้อง stamp ก่อน `document.save()`)
|
||||
|
||||
### AC-8 — ดาวน์โหลด version เก่าได้ไฟล์ที่เปิดได้ เนื้อหาตรงตอน upload — ✅
|
||||
- `UploadVersionTests.Download_ReturnsExactUploadedBytes_WithDocxContentType`: byte-for-byte ผ่าน MinIO ไป-กลับ + content-type ถูก
|
||||
- 🖐️ บน Mac ใช้ presigned link + `openBrowserWindow` (แก้จาก blob download ที่ WKWebView ทิ้งเงียบๆ) — ผ่านการตรวจ curl แล้ว ยืนยัน click จริงอีกครั้งพร้อม Word Online
|
||||
|
||||
### AC-9 — token หมดอายุกลาง session → silent refresh ผู้ใช้ไม่รู้สึก — ✅
|
||||
- `apiClient.test.ts`: 401 → refresh → retry ครั้งเดียว → ยัง 401 → สัญญาณกลับหน้า sign-in · `useAuth`/`useApi` ของ web ใช้พฤติกรรมเดียวกัน
|
||||
- 🔶 รอยืนยันด้วยมือ (ปล่อย session ทิ้งไว้จริง)
|
||||
|
||||
### AC-10 — XML Part fail silent บน Mac → ระบบยังทำงานถูก (backend = source of truth) — ✅
|
||||
- `metadataService.test.ts`: XML write fail → catch ไม่ throw · ทุก history อ่านจาก backend เสมอ
|
||||
|
||||
### AC-11 — upload template → detect fields อัตโนมัติ + บันทึก definitions — ✅
|
||||
- `TemplateTests`: SDT ครบทุกรูปแบบ (run เดี่ยว / multi-run คนละ formatting / block / tag ซ้ำ → dedup) + defaults (label=tag, text, required=false) + PUT บันทึกนิยาม + reject key นอกไฟล์/type ผิด
|
||||
- 🖐️ หน้าเว็บอัปโหลด: ตรวจ SSR + จำลอง request sequence แล้ว (Phase 6) รอ click-through บน browser
|
||||
|
||||
### AC-12 — กรอก form บนเว็บ → docx ค่าครบ formatting ไม่พัง → เปิดใน Word เห็น tracked + v1 ทันที — ✅ (backend) + 🔶 (เต็มรูปแบบรอมือ)
|
||||
- `GenerateTests.Generate_FillsAllFields_...`: ค่าครบทุก SDT + **multi-run รวมเป็น run เดียวโดยรักษา bold ของ run แรก** + custom properties (`SavedByAddIn` = GUID ตรงเป๊ะ, `DocumentTrackingId` = documentId) + Document/v1/field values ใน Postgres ครบ
|
||||
- 🖐️ ส่วน "เปิดใน Word แล้ว add-in เห็น v1 ทันที": ตรวจด้วย unzip/OpenXML แล้ว ต้องยืนยัน click จริงบน Word
|
||||
|
||||
### AC-13 — แก้ค่าใน content control แล้ว Save & Version → field value ใน DB เปลี่ยน + snapshot ต่อ version — ✅
|
||||
- `FieldSyncTests.UploadWithChangedFields_...`: latest values อัปเดตเฉพาะ field ที่เปลี่ยน (`SourceVersionId` ชี้ v2) + snapshot v1/v2 เก็บค่าต่างกันตามเวลา (diff ได้)
|
||||
|
||||
### AC-14 — ลบ content control แล้ว Save & Version → flow สำเร็จ ค่าเดิมคงอยู่ mark missing ไม่ error — ✅
|
||||
- `FieldSyncTests.UploadWithMissingFields_...`: request 201 · field ที่หายคงค่า v1 + `SourceVersionId` ไม่ขยับ · snapshot `missing:true` พร้อมค่าเดิม · field เกินนิยามถูก ignore ไม่เก็บ
|
||||
|
||||
### AC-15 — หน้ารายการเอกสารของ template + detail (fields + history + snapshot ต่อ version + ดาวน์โหลด) + re-generate เป็น v1 โดยต้นทางไม่ถูกแตะ — ✅
|
||||
- `GenerateTests.ListDocuments_ShowsVersionCounts_LatestFirst` (versionCount/Latest + เรียงล่าสุดก่อน) + `GetDetail_MergesDefsAndValues_BothOrphanWays` (merge นิยาม × ค่า รองรับ orphan สองทาง) + `GetDetail_OfStandaloneDocument`
|
||||
- Re-generate: ผ่าน composition เดิม (detail → prefill → generate) — ตรวจ curl จริง Phase 6.4 ว่า **versionCount ของเอกสารต้นทางคงเดิม** + `TemplateTests.Deactivate_...` ยืนยัน policy deactivate
|
||||
- 🖐️ หน้าเว็บ: SSR ทุก route 200 แล้ว รอ click-through
|
||||
|
||||
## ชุด test ที่คุ้ม AC (สรุป)
|
||||
|
||||
| ชุด | จำนวน | ครอบ |
|
||||
|---|---|---|
|
||||
| backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 42 | AC-3, 5, 6, 7 (backend), 8, 11–15 |
|
||||
| addin unit (Jest) | 47 | AC-2, 4, 7 (client flow), 9, 10 |
|
||||
| web unit (Vitest) | 36 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering |
|
||||
|
||||
## ปัญหาที่เจอระหว่างไล่ AC (และการแก้)
|
||||
|
||||
1. **Keycloak ใน Testcontainers พร้อมช้ากว่า port เปิด** — docker-proxy รับ connection ก่อนแล้วตัดสาย ("response ended prematurely") → fixture poll discovery endpoint จนได้ 200 จริง + แนบ container logs ตอน timeout
|
||||
2. **`WithResourceMapping` ของ Testcontainers 4.14 รับ target เป็น directory** — ชี้ path ไฟล์ตรงๆ ทำให้ realm ไม่ถูก import (discovery 404 ตลอด 2 นาที) → ชี้ `/opt/keycloak/data/import` แล้วไฟล์เข้าไปชื่อเดิม
|
||||
3. **Keycloak 26 บังคับ HTTPS บน token endpoint ของ master realm แม้ใน container http** → เลิกใช้ master admin API สร้าง test user ตามแผน ใช้ `testuser` ที่มากับ realm import + password grant แทน (backend ยัง validate JWKS/issuer/audience เต็มขั้น)
|
||||
4. **template .docx ที่ test สร้างเองผิด schema** — วาง run-level SDT เป็นลูกตรงของ Body (OOXML ห้าม; Word วางไว้ใน paragraph เสมอ) ตอน reload กลายเป็น SdtBlock + unknown elements ทั้งชุด ทำให้ generate เข้า branch ผิด → แก้ `TestDocx` ให้ถูก schema (service ฝั่ง backend ถูกอยู่แล้ว — ยืนยันด้วย template จริงตั้งแต่ Phase 3)
|
||||
5. **`import.meta.client` ของ Nuxt ไม่ถูก replace ใน Vitest** (vite-node ไม่ apply define กับ import.meta.*) → `useTheme` เปลี่ยน guard เป็น `typeof window !== 'undefined'` (semantics เทียบเท่าใน SSR/client)
|
||||
6. **ยืนยัน behavior ผ่าน test (ไม่ใช่ bug):** field sync update latest value ของทุก field ที่ "ส่งมา" แม้ค่าไม่เปลี่ยน (`SourceVersionId` ขยับทุกตัวที่ส่ง) — เฉพาะ field ที่ "ไม่ได้ส่ง" เท่านั้นที่คงค่า+source เดิม ตาม policy FR-11
|
||||
7. (ย้อนอดีต — จาก Phase 5) Office Dialog API ปฏิเสธ http, slice.data หลายรูปแบบ, WKWebView บล็อก mixed content + blob download — แก้ครบแล้ว มี regression coverage ใน unit tests
|
||||
|
||||
## ที่เหลือ (ต้องทำด้วยมือ — ทำแทนไม่ได้)
|
||||
|
||||
- [ ] Word Online: sideload + login + full flow (AC-1, 2, 4–8 บน Online)
|
||||
- [ ] Word for Windows (ถ้ามีเครื่อง)
|
||||
- [ ] ปล่อย session จริงจน token หมดอายุ → ยืนยัน silent refresh (AC-9)
|
||||
- [ ] Click-through บน browser: สร้าง/แก้ template → สร้างเอกสาร → ดาวน์โหลด → เปิดใน Word ยืนยัน add-in เห็น v1 ทันที (AC-11, 12) + ดาวน์โหลด version ผ่าน presigned link บน Mac อีกครั้ง (AC-8)
|
||||
- [ ] Theme toggle จำค่าหลัง reload บน browser จริง (Phase 6 DoD ที่เหลือ)
|
||||
|
|
@ -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: { <fieldKey>: <value> } }
|
|||
|
||||
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
|
||||
|
||||
> 🟡 implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · **ยังเหลือ**: ยืนยันการดาวน์โหลดผ่าน presigned link หลังแก้รอบล่าสุด, ไล่ AC เต็มชุด (โดยเฉพาะ AC-9/10/13) และทดสอบบน Word Online/Windows
|
||||
> 🟡→ อัปเดต (2026-08-20): implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · รายการ "ยังเหลือ" เดิมถูกแทนที่: AC-13/10 คุ้มแล้วด้วย integration tests ของ Phase 7 ส่วนที่เหลือ (ยืนยัน presigned link บน Mac อีกครั้ง, Word Online/Windows, AC-9 จริง) รวบอยู่ใน checklist งานมือท้าย **[`docs/acceptance-review.md`](../acceptance-review.md)**
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -442,7 +453,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
|
||||
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
|
||||
|
||||
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · **ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ต้องยืนยันด้วยมือ** ร่วมกับ AC checklist ที่เหลือใน Phase 8
|
||||
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ฝั่ง backend/generate คุ้มแล้วด้วย integration tests (custom properties + v1 ครบ) — **การยืนยันบน Word จริงอยู่ใน checklist งานมือท้าย [`docs/acceptance-review.md`](../acceptance-review.md)** (ตอนนั้นยังต้องรอ Phase 8 ตอนนี้ไล่ครบแล้ว)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -450,27 +461,43 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
|
||||
**เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่
|
||||
|
||||
- [ ] **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<Program>` (ต้องเพิ่ม `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 <Name>
|
||||
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 ตัว)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
446
web/package-lock.json
generated
446
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
web/test/fieldValidation.spec.ts
Normal file
84
web/test/fieldValidation.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { validateFieldValues, type FieldDefLike } from '../app/utils/fieldValidation'
|
||||
|
||||
/** ชุดนิยามครบทุก type ที่ระบบรองรับ (ตาม TemplateField: text/number/date) */
|
||||
const defs: FieldDefLike[] = [
|
||||
{ key: 'employee_name', label: 'ชื่อพนักงาน', type: 'text', isRequired: true },
|
||||
{ key: 'position', label: 'ตำแหน่ง', type: 'text', isRequired: false },
|
||||
{ key: 'salary', label: 'เงินเดือน', type: 'number', isRequired: false },
|
||||
{ key: 'start_date', label: 'วันเริ่มงาน', type: 'date', isRequired: false },
|
||||
]
|
||||
|
||||
describe('validateFieldValues — dynamic form ของหน้าสร้างเอกสาร (FR-10)', () => {
|
||||
it('ค่าครบและถูก type ทุก field → ไม่มี issue', () => {
|
||||
const issues = validateFieldValues(defs, {
|
||||
employee_name: 'สมหญิง รักงาน',
|
||||
position: 'วิศวกรซอฟต์แวร์',
|
||||
salary: '75000',
|
||||
start_date: '2026-08-01',
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
})
|
||||
|
||||
describe('required', () => {
|
||||
it('required ขาด (ไม่มี key) → issue ชี้ field นั้น', () => {
|
||||
const issues = validateFieldValues(defs, { salary: '1' })
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0].key).toBe('employee_name')
|
||||
expect(issues[0].message).toContain('ชื่อพนักงาน')
|
||||
})
|
||||
|
||||
it('required ว่างเปล่า / มีแต่วรรค → issue (ค่าที่เป็นช่องว่างนับเป็นว่าง)', () => {
|
||||
expect(validateFieldValues(defs, { employee_name: '' })[0].key).toBe('employee_name')
|
||||
expect(validateFieldValues(defs, { employee_name: ' ' })[0].key).toBe('employee_name')
|
||||
})
|
||||
|
||||
it('ไม่ required และว่าง → ผ่าน (ไม่บังคับ type ด้วย)', () => {
|
||||
const issues = validateFieldValues(defs, {
|
||||
employee_name: 'มีชื่อ',
|
||||
position: '',
|
||||
salary: '',
|
||||
start_date: '',
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('type = number (ตรง NumberStyles.Number ของ backend)', () => {
|
||||
it.each(['1', '-5', '+3.14', '1,234.56', '0'])('ค่า %s ผ่าน', (value) => {
|
||||
expect(validateFieldValues(defs, { employee_name: 'x', salary: value })).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['abc', '1.2.3', '12.', '.5', '1e5', '--2'])('ค่า %s เป็น issue', (value) => {
|
||||
const issues = validateFieldValues(defs, { employee_name: 'x', salary: value })
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0].message).toContain('ตัวเลข')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type = date', () => {
|
||||
it.each(['2026-08-01', '2026-08-01T09:00:00Z', '2026-12-31'])('ค่า %s ผ่าน', (value) => {
|
||||
expect(validateFieldValues(defs, { employee_name: 'x', start_date: value })).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['not-a-date', '31/02/2026 ผิดรูป', 'สิงหาคม'])('ค่า %s เป็น issue', (value) => {
|
||||
const issues = validateFieldValues(defs, { employee_name: 'x', start_date: value })
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0].message).toContain('วันที่')
|
||||
})
|
||||
})
|
||||
|
||||
it('ผิดหลาย field พร้อมกัน → issue เรียงตามลำดับ defs และ issue ละ field เดียว', () => {
|
||||
const issues = validateFieldValues(defs, {
|
||||
employee_name: 'มีชื่อแล้ว', // required ผ่าน เหลือดูเฉพาะ type ของ salary/start_date
|
||||
salary: 'abc',
|
||||
start_date: 'วันนี้',
|
||||
})
|
||||
expect(issues.map((i) => i.key)).toEqual(['salary', 'start_date'])
|
||||
})
|
||||
|
||||
it('type อื่นที่ไม่รู้จัก (เช่น จาก backend อนาคต) → ไม่ validate เพิ่ม', () => {
|
||||
const futureDefs: FieldDefLike[] = [{ key: 'x', label: 'อะไรใหม่', type: 'email', isRequired: false }]
|
||||
expect(validateFieldValues(futureDefs, { x: 'ไม่ใช่ email' })).toEqual([])
|
||||
})
|
||||
})
|
||||
27
web/test/setup.ts
Normal file
27
web/test/setup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Stub Nuxt auto-imports ที่ composable ใช้ — เบากว่าการลาก @nuxt/test-utils ทั้งชุดเข้ามา
|
||||
* - useState: ตาม semantics ของ Nuxt คือ state แชร์กันตาม key (สร้างครั้งแรก ครั้งถัดไปได้ตัวเดิม)
|
||||
* - computed: ของ vue ตรงๆ
|
||||
* เคลียร์ state ทุก test เพื่อไม่ให้ theme ของ test ก่อนหน้ารั่วมา
|
||||
*/
|
||||
import { beforeEach } from 'vitest'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
|
||||
const sharedState = new Map<string, Ref<unknown>>()
|
||||
|
||||
;(globalThis as Record<string, unknown>).useState = <T>(key: string, init: () => T): Ref<T> => {
|
||||
let entry = sharedState.get(key)
|
||||
if (!entry) {
|
||||
entry = ref(init()) as Ref<unknown>
|
||||
sharedState.set(key, entry)
|
||||
}
|
||||
return entry as Ref<T>
|
||||
}
|
||||
|
||||
;(globalThis as Record<string, unknown>).computed = computed
|
||||
|
||||
beforeEach(() => {
|
||||
sharedState.clear()
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.clear()
|
||||
})
|
||||
77
web/test/snapshot.spec.ts
Normal file
77
web/test/snapshot.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeSnapshot } from '../app/utils/snapshot'
|
||||
|
||||
describe('normalizeSnapshot — แปลง snapshot 2 รูปแบบของ backend ให้เป็น list สำหรับ UI (FR-12)', () => {
|
||||
it('รูปแบบเอกสารผูก template: { key: { value, missing } }', () => {
|
||||
const result = normalizeSnapshot({
|
||||
employee_name: { value: 'สมหญิง รักงาน', missing: false },
|
||||
position: { value: 'วิศวกรเดิม', missing: true },
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{ key: 'employee_name', value: 'สมหญิง รักงาน', missing: false },
|
||||
{ key: 'position', value: 'วิศวกรเดิม', missing: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('รูปแบบเอกสารไม่ผูก template: map ดิบ { key: value } → missing=false ทั้งหมด', () => {
|
||||
const result = normalizeSnapshot({ any_tag: 'ค่าอะไรก็ได้' })
|
||||
|
||||
expect(result).toEqual([{ key: 'any_tag', value: 'ค่าอะไรก็ได้', missing: false }])
|
||||
})
|
||||
|
||||
it('value ไม่ใช่ string → แปลงเป็น string; null/undefined → ""', () => {
|
||||
const result = normalizeSnapshot({
|
||||
number_value: 123,
|
||||
bool_value: true,
|
||||
null_value: null,
|
||||
undefined_value: undefined,
|
||||
object_value: { value: null, missing: false },
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{ key: 'number_value', value: '123', missing: false },
|
||||
{ key: 'bool_value', value: 'true', missing: false },
|
||||
{ key: 'null_value', value: '', missing: false },
|
||||
{ key: 'undefined_value', value: '', missing: false },
|
||||
{ key: 'object_value', value: '', missing: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('fields เป็น null/undefined/ค่าไม่ใช่ object → list ว่าง (version ไม่มี snapshot)', () => {
|
||||
expect(normalizeSnapshot(null)).toEqual([])
|
||||
expect(normalizeSnapshot(undefined)).toEqual([])
|
||||
expect(normalizeSnapshot('string')).toEqual([])
|
||||
expect(normalizeSnapshot(42)).toEqual([])
|
||||
})
|
||||
|
||||
it('orderKeys กำหนดลำดับตาม field defs — key นอก defs ต่อท้ายตามตัวอักษร', () => {
|
||||
const result = normalizeSnapshot(
|
||||
{ orphan: '1', salary: '2', employee_name: '3', zzz: '4', position: '5' },
|
||||
['employee_name', 'position', 'salary'],
|
||||
)
|
||||
|
||||
expect(result.map((e) => e.key)).toEqual([
|
||||
'employee_name',
|
||||
'position',
|
||||
'salary',
|
||||
'orphan',
|
||||
'zzz', // orphan สองตัวเรียงตาม localeCompare
|
||||
])
|
||||
})
|
||||
|
||||
it('orderKeys ว่าง/ไม่ส่ง → คงลำดับเดิมของ object', () => {
|
||||
const fields = { b: 'x', a: 'y' }
|
||||
|
||||
expect(normalizeSnapshot(fields, []).map((e) => e.key)).toEqual(['b', 'a'])
|
||||
expect(normalizeSnapshot(fields).map((e) => e.key)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('key ซ้ำใน object (ผ่าน JSON เข้ามา) — ตัวหลังชนะตามพฤติกรรม Object.entries', () => {
|
||||
// JSON.parse ทำให้ key ซ้ำเหลือตัวเดียวอยู่แล้ว — assert กัน regression เรื่อง Map dedupe
|
||||
const parsed = JSON.parse('{"k":"first","k":"second"}')
|
||||
const result = normalizeSnapshot(parsed)
|
||||
|
||||
expect(result).toEqual([{ key: 'k', value: 'second', missing: false }])
|
||||
})
|
||||
})
|
||||
84
web/test/useTheme.spec.ts
Normal file
84
web/test/useTheme.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useTheme } from '../app/composables/useTheme'
|
||||
|
||||
/** กำหนด prefers-color-scheme ของระบบ — initTheme ใช้เป็นค่า default เมื่อยังไม่มี preference */
|
||||
function mockPrefersDark(matches: boolean) {
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches }))
|
||||
}
|
||||
|
||||
describe('useTheme — light/dark toggle + persist + system default (Phase 6.1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('state เริ่มที่ light และ toggle สลับไป-กลับ พร้อม class บน <html>', () => {
|
||||
mockPrefersDark(false)
|
||||
const { theme, isDark, toggle } = useTheme()
|
||||
|
||||
expect(theme.value).toBe('light')
|
||||
expect(isDark.value).toBe(false)
|
||||
|
||||
toggle()
|
||||
expect(theme.value).toBe('dark')
|
||||
expect(isDark.value).toBe(true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
toggle()
|
||||
expect(theme.value).toBe('light')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggle เขียน preference ลง localStorage (ข้อยกเว้นเดียวที่อนุญาต — ไม่ใช่ credential)', () => {
|
||||
mockPrefersDark(false)
|
||||
const { toggle } = useTheme()
|
||||
|
||||
toggle()
|
||||
expect(localStorage.getItem('theme-preference')).toBe('dark')
|
||||
|
||||
toggle()
|
||||
expect(localStorage.getItem('theme-preference')).toBe('light')
|
||||
})
|
||||
|
||||
it('initTheme ใช้ค่าที่เคยบันทึกไว้ (แม้ระบบเป็นอีกค่า)', () => {
|
||||
mockPrefersDark(true)
|
||||
localStorage.setItem('theme-preference', 'light')
|
||||
const { theme, initTheme } = useTheme()
|
||||
|
||||
initTheme()
|
||||
|
||||
expect(theme.value).toBe('light') // stored ชนะ system
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('initTheme ไม่มีค่าเก็บไว้ → ตาม prefers-color-scheme ของระบบ', () => {
|
||||
mockPrefersDark(true)
|
||||
const { theme, isDark, initTheme } = useTheme()
|
||||
|
||||
initTheme()
|
||||
|
||||
expect(theme.value).toBe('dark')
|
||||
expect(isDark.value).toBe(true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
})
|
||||
|
||||
it('ค่าใน localStorage ที่ไม่ใช่ light/dark → ตกไปใช้ค่าระบบแทน (กันค่าเสีย)', () => {
|
||||
mockPrefersDark(false)
|
||||
localStorage.setItem('theme-preference', 'neon')
|
||||
const { theme, initTheme } = useTheme()
|
||||
|
||||
initTheme()
|
||||
|
||||
expect(theme.value).toBe('light') // ค่าเสียถูกเมิน ใช้ system default
|
||||
})
|
||||
|
||||
it('state แชร์กันตาม key (semantics ของ useState) — สอง instance เห็นค่าเดียวกัน', () => {
|
||||
mockPrefersDark(false)
|
||||
const first = useTheme()
|
||||
const second = useTheme()
|
||||
|
||||
first.toggle()
|
||||
|
||||
expect(second.theme.value).toBe('dark')
|
||||
expect(second.isDark.value).toBe(true)
|
||||
})
|
||||
})
|
||||
12
web/vitest.config.ts
Normal file
12
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Unit test สำหรับ pure functions (fieldValidation/snapshot) + useTheme (ต้องมี DOM)
|
||||
// ไม่ดึง @nuxt/test-utils — stub เฉพาะ auto-imports ที่ใช้จริง (test/setup.ts) ให้เบาและอธิบายตัวเอง
|
||||
// (useTheme แยก client/server ด้วย typeof window จึงไม่ต้อง replace import.meta.client)
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['test/**/*.spec.ts'],
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue