implement phase 4
This commit is contained in:
parent
25c2ce53cd
commit
f4b1c3e14b
27 changed files with 6929 additions and 48 deletions
52
README.md
52
README.md
|
|
@ -34,11 +34,12 @@
|
||||||
| 1 | Local dev infra — docker-compose + Keycloak (realm, 3 clients + PKCE) + MinIO (buckets) | ✅ เสร็จ |
|
| 1 | Local dev infra — docker-compose + Keycloak (realm, 3 clients + PKCE) + MinIO (buckets) | ✅ เสร็จ |
|
||||||
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
|
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
|
||||||
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
|
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
|
||||||
| 4–5 | Add-in services + task pane UI (FR-1–4) | ⏳ รอ implement |
|
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
|
||||||
|
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ⏳ รอ implement |
|
||||||
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
|
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
|
||||||
| 7–8 | Tests + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
||||||
|
|
||||||
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง)
|
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in มี services layer พร้อมใช้ครบพร้อม unit tests 40 ตัว (รอ Phase 5 เอาไปต่อ UI)
|
||||||
|
|
||||||
## เอกสาร
|
## เอกสาร
|
||||||
|
|
||||||
|
|
@ -56,7 +57,7 @@
|
||||||
```bash
|
```bash
|
||||||
dotnet tool install --global dotnet-ef --version 8.0.11
|
dotnet tool install --global dotnet-ef --version 8.0.11
|
||||||
```
|
```
|
||||||
- Node.js LTS (add-in และ web — ยังเป็น scaffold)
|
- Node.js LTS (add-in และ web)
|
||||||
|
|
||||||
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
||||||
|
|
||||||
|
|
@ -185,14 +186,49 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
|
||||||
|
|
||||||
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
|
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
|
||||||
|
|
||||||
### 3) Add-in และ Web Portal (ยังเป็น scaffold)
|
### 3) Add-in (services layer พร้อม — UI ตามมาใน Phase 5)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd addin && npm start # https://localhost:3000 — ครั้งแรก: npx office-addin-dev-certs install
|
cd addin
|
||||||
cd web && npm run dev # http://localhost:5173
|
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
||||||
|
npm run build # webpack build + type-check ทั้ง program
|
||||||
|
npm test # Jest — unit tests ของ services layer (40 ตัว)
|
||||||
|
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
|
||||||
|
npm run validate # ตรวจ manifest.xml
|
||||||
|
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
สองส่วนนี้ยังเป็นโครงจาก Phase 0 — logic จริง (login, Save & Version, template UI) มาตาม Phase 4–6
|
**Services layer ที่มีแล้ว** ([addin/src/services/](addin/src/services/)) — ทุกตัวมี unit tests ครอบ (mock `Word.run`, `Office.context.ui`, `fetch`):
|
||||||
|
|
||||||
|
| ไฟล์ | หน้าที่ |
|
||||||
|
|---|---|
|
||||||
|
| `authService.ts` | OIDC Authorization Code + PKCE (S256) ผ่าน Office Dialog API, token ใน memory เท่านั้น, silent refresh, state กัน CSRF |
|
||||||
|
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
|
||||||
|
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
|
||||||
|
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
|
||||||
|
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion |
|
||||||
|
|
||||||
|
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
|
||||||
|
|
||||||
|
**Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYCLOAK_AUTHORITY=http://localhost:8080/realms/word-versioning \
|
||||||
|
API_BASE_URL=http://localhost:5075 \
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
(ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`)
|
||||||
|
|
||||||
|
**ข้อจำกัดชั่วคราว:** task pane ยังเป็นหน้า demo ของ scaffold — Phase 5 จะเอา services ชุดนี้ไป wire เป็น UI จริง (sign-in → detect → Save & Version → history)
|
||||||
|
|
||||||
|
### 4) Web Portal (ยังเป็น scaffold)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in)
|
||||||
|
```
|
||||||
|
|
||||||
|
logic จริง (login, template UI, dynamic form) มาตาม Phase 6
|
||||||
|
|
||||||
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
|
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
|
||||||
|
|
||||||
|
|
|
||||||
46
addin/eslint.config.mjs
Normal file
46
addin/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import officeAddins from "eslint-plugin-office-addins";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
// สืบทอดจาก config เริ่มต้นของ office-addin-lint (node_modules/office-addin-lint/config/eslint.config.mjs)
|
||||||
|
// — office-addin-lint จะใช้ไฟล์นี้แทนถ้ามีอยู่ใน root ของโปรเจกต์
|
||||||
|
//
|
||||||
|
// ปิด `no-navigational-load` เพราะ rule ของ plugin เวอร์ชันนี้ (4.0.10):
|
||||||
|
// (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (parsePropertiesArgument(undefined))
|
||||||
|
// (b) ไม่รู้จัก Word API properties (เช่น `tag`) จึงรายงาน false positive
|
||||||
|
export default [
|
||||||
|
...officeAddins.configs.recommended,
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"office-addins": officeAddins,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
// task pane รันใน browser (Office) แต่ unit test/jest setup ใช้ node globals ด้วย
|
||||||
|
globals: { ...globals.browser, ...globals.node, ...globals.jest },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"office-addins/no-navigational-load": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ไฟล์ TS: tsc ตรวจ undefined globals อยู่แล้ว (แม่นกว่า no-undef ที่ไม่เข้าใจ types)
|
||||||
|
// และ no-redeclare ชนกับ /* global */ comments ของ scaffold
|
||||||
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-redeclare": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
13
addin/jest.config.js
Normal file
13
addin/jest.config.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests ของ services layer — แปลง TS ด้วย babel-jest (ใช้ babel.config.json เดิม
|
||||||
|
* ที่มี @babel/preset-typescript อยู่แล้ว — type-check ทำที่ npm run build ผ่าน ts-loader)
|
||||||
|
* environment = jsdom เพราะ services ใช้ DOM API (DOMParser, Blob, btoa, URLSearchParams)
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: "jsdom",
|
||||||
|
roots: ["<rootDir>/src"],
|
||||||
|
setupFiles: ["<rootDir>/src/test/setup.ts"],
|
||||||
|
testMatch: ["**/*.test.ts"],
|
||||||
|
};
|
||||||
|
|
@ -15,6 +15,15 @@
|
||||||
<Hosts>
|
<Hosts>
|
||||||
<Host Name="Document"/>
|
<Host Name="Document"/>
|
||||||
</Hosts>
|
</Hosts>
|
||||||
|
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
|
||||||
|
WordApi 1.5: customProperties / customXmlParts / document.save()
|
||||||
|
DialogAPI 1.1: Office Dialog API สำหรับ login ผ่าน Keycloak (FR-8) -->
|
||||||
|
<Requirements>
|
||||||
|
<Sets DefaultMinVersion="1.1">
|
||||||
|
<Set Name="WordApi" MinVersion="1.5"/>
|
||||||
|
<Set Name="DialogAPI" MinVersion="1.1"/>
|
||||||
|
</Sets>
|
||||||
|
</Requirements>
|
||||||
<DefaultSettings>
|
<DefaultSettings>
|
||||||
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||||
</DefaultSettings>
|
</DefaultSettings>
|
||||||
|
|
|
||||||
4796
addin/package-lock.json
generated
4796
addin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,7 @@
|
||||||
"signout": "office-addin-dev-settings m365-account logout",
|
"signout": "office-addin-dev-settings m365-account logout",
|
||||||
"start": "office-addin-debugging start manifest.xml",
|
"start": "office-addin-debugging start manifest.xml",
|
||||||
"stop": "office-addin-debugging stop manifest.xml",
|
"stop": "office-addin-debugging stop manifest.xml",
|
||||||
|
"test": "jest",
|
||||||
"validate": "office-addin-manifest validate manifest.xml",
|
"validate": "office-addin-manifest validate manifest.xml",
|
||||||
"watch": "webpack --mode development --watch"
|
"watch": "webpack --mode development --watch"
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +39,8 @@
|
||||||
"@babel/core": "^8.0.1",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/preset-env": "^8.0.2",
|
"@babel/preset-env": "^8.0.2",
|
||||||
"@babel/preset-typescript": "^8.0.1",
|
"@babel/preset-typescript": "^8.0.1",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
"@types/office-js": "^1.0.377",
|
"@types/office-js": "^1.0.377",
|
||||||
"@types/office-runtime": "^1.0.35",
|
"@types/office-runtime": "^1.0.35",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
|
|
@ -51,6 +54,8 @@
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-loader": "^5.0.0",
|
"html-loader": "^5.0.0",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"less": "^4.2.0",
|
"less": "^4.2.0",
|
||||||
"less-loader": "^12.2.0",
|
"less-loader": "^12.2.0",
|
||||||
"office-addin-cli": "^2.0.10",
|
"office-addin-cli": "^2.0.10",
|
||||||
|
|
|
||||||
19
addin/src/config.ts
Normal file
19
addin/src/config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Config ของ add-in — inject ผ่าน webpack DefinePlugin ตอน build (อ่านจาก process.env)
|
||||||
|
* ค่า default คือ local dev: Keycloak จาก docker-compose (port 8080), backend `dotnet run` (port 5075)
|
||||||
|
* ถ้าจะชี้ที่อื่น: `KEYCLOAK_AUTHORITY=... API_BASE_URL=... npm run build`
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
keycloak: {
|
||||||
|
/** เช่น http://localhost:8080/realms/word-versioning */
|
||||||
|
authority: __KEYCLOAK_AUTHORITY__,
|
||||||
|
/** public client ของ add-in (PKCE S256 บังคับ) */
|
||||||
|
clientId: __KEYCLOAK_CLIENT_ID__,
|
||||||
|
},
|
||||||
|
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */
|
||||||
|
apiBaseUrl: __API_BASE_URL__,
|
||||||
|
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
|
||||||
|
get redirectUri(): string {
|
||||||
|
return `${window.location.origin}/auth-callback.html`;
|
||||||
|
},
|
||||||
|
};
|
||||||
5
addin/src/globals.d.ts
vendored
Normal file
5
addin/src/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Compile-time constants ที่ webpack DefinePlugin แทนที่ตอน build (ค่าจาก process.env — ดู webpack.config.js)
|
||||||
|
// ตามกติกา "config แยกเป็น env เท่านั้น ห้าม hardcode" — ค่า default สำหรับ local dev อยู่ที่ webpack.config.js
|
||||||
|
declare const __KEYCLOAK_AUTHORITY__: string;
|
||||||
|
declare const __KEYCLOAK_CLIENT_ID__: string;
|
||||||
|
declare const __API_BASE_URL__: string;
|
||||||
53
addin/src/models/versionRecord.ts
Normal file
53
addin/src/models/versionRecord.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** response ของ POST /api/documents/{documentId}/versions และ POST /api/documents/generate */
|
||||||
|
export interface UploadVersionResult {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
|
||||||
|
export interface FieldSnapshotEntry {
|
||||||
|
value: string;
|
||||||
|
missing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
|
||||||
|
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
|
||||||
|
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
|
||||||
|
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
|
||||||
|
*/
|
||||||
|
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
|
||||||
|
|
||||||
|
/** item ของ GET /api/documents/{documentId}/versions */
|
||||||
|
export interface VersionRecord {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
/** ISO-8601 */
|
||||||
|
createdAt: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
/** SHA256 hex */
|
||||||
|
contentHash: string;
|
||||||
|
fields: FieldsSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
|
||||||
|
export function readSnapshotField(
|
||||||
|
snapshot: FieldsSnapshot | null,
|
||||||
|
key: string
|
||||||
|
): FieldSnapshotEntry | null {
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = snapshot[key];
|
||||||
|
if (raw === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return { value: raw, missing: false };
|
||||||
|
}
|
||||||
|
return { value: raw.value, missing: raw.missing };
|
||||||
|
}
|
||||||
154
addin/src/services/apiClient.test.ts
Normal file
154
addin/src/services/apiClient.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
|
||||||
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||||
|
|
||||||
|
const API_URL = "http://localhost:5075";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authService.signOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
authService.signOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** header ที่ request แต่ละ call ส่งจริง */
|
||||||
|
function authHeaderOf(call: [unknown, { headers?: unknown }]): string | null {
|
||||||
|
return new Headers(call[1]?.headers as HeadersInit).get("Authorization");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("apiFetch", () => {
|
||||||
|
it("แนบ Authorization: Bearer ทุก request และต่อ path กับ apiBaseUrl", async () => {
|
||||||
|
const token = makeFakeJwt(Date.now() + 600_000);
|
||||||
|
authService.setSessionForTest(token, "refresh-1", Date.now() + 600_000);
|
||||||
|
const fetchMock = installFetchMock(async () =>
|
||||||
|
mockResponse({ status: 200, body: { ok: true } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await apiFetch("/api/documents/doc-1/versions");
|
||||||
|
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string, unknown];
|
||||||
|
expect(url).toBe(`${API_URL}/api/documents/doc-1/versions`);
|
||||||
|
expect(authHeaderOf(fetchMock.mock.calls[0] as [string, { headers?: unknown }])).toBe(
|
||||||
|
`Bearer ${token}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("โดน 401 → refresh แล้ว retry ครั้งเดียวด้วย token ใหม่ จนสำเร็จ", async () => {
|
||||||
|
const oldToken = makeFakeJwt(Date.now() + 600_000);
|
||||||
|
const newToken = makeFakeJwt(Date.now() + 900_000);
|
||||||
|
authService.setSessionForTest(oldToken, "refresh-old", Date.now() + 600_000);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
|
||||||
|
.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({
|
||||||
|
status: 200,
|
||||||
|
body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 900 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 200, body: [] }));
|
||||||
|
|
||||||
|
const response = await apiFetch("/api/documents/doc-1/versions");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
const headers = fetchMock.mock.calls.map((call) =>
|
||||||
|
authHeaderOf(call as [string, { headers?: unknown }])
|
||||||
|
);
|
||||||
|
expect(headers[0]).toBe(`Bearer ${oldToken}`); // request แรกโดน 401
|
||||||
|
expect(headers[1]).toBeNull(); // token endpoint ไม่ใช่ apiFetch — ไม่มี Bearer
|
||||||
|
expect(headers[2]).toBe(`Bearer ${newToken}`); // retry ด้วย token ใหม่
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retry แล้วยัง 401 อีก → ยอมแพ้เป็น AuthRequiredError (ให้ UI กลับหน้า sign-in)", async () => {
|
||||||
|
authService.setSessionForTest(
|
||||||
|
makeFakeJwt(Date.now() + 600_000),
|
||||||
|
"refresh-old",
|
||||||
|
Date.now() + 600_000
|
||||||
|
);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
|
||||||
|
.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({
|
||||||
|
status: 200,
|
||||||
|
body: { access_token: makeFakeJwt(Date.now() + 900_000), expires_in: 900 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "still rejected" }));
|
||||||
|
|
||||||
|
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 แล้ว refresh ไม่ได้ (refresh token ตาย) → AuthRequiredError ทันที ไม่ retry", async () => {
|
||||||
|
authService.setSessionForTest(
|
||||||
|
makeFakeJwt(Date.now() + 600_000),
|
||||||
|
"refresh-dead",
|
||||||
|
Date.now() + 600_000
|
||||||
|
);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
|
||||||
|
.mockImplementationOnce(async () => mockResponse({ status: 400, text: "invalid_grant" }));
|
||||||
|
|
||||||
|
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่มี token เลย → AuthRequiredError โดยไม่ยิง fetch", async () => {
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiGetJson", () => {
|
||||||
|
it("parse JSON ตอน 200", async () => {
|
||||||
|
authService.setSessionForTest(makeFakeJwt(Date.now() + 600_000), null, Date.now() + 600_000);
|
||||||
|
installFetchMock(async () =>
|
||||||
|
mockResponse({ status: 200, body: [{ versionId: "v1", versionNumber: 1 }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(apiGetJson("/api/documents/doc-1/versions", "fallback")).resolves.toEqual([
|
||||||
|
{ versionId: "v1", versionNumber: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status ไม่สำเร็จ → ApiError พร้อม message จาก body ของ backend", async () => {
|
||||||
|
authService.setSessionForTest(makeFakeJwt(Date.now() + 600_000), null, Date.now() + 600_000);
|
||||||
|
installFetchMock(async () =>
|
||||||
|
mockResponse({ status: 400, text: "รองรับเฉพาะไฟล์ .docx (Word document)" })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(apiGetJson("/api/documents/doc-1/versions", "fallback")).rejects.toMatchObject({
|
||||||
|
name: "ApiError",
|
||||||
|
status: 400,
|
||||||
|
message: "รองรับเฉพาะไฟล์ .docx (Word document)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readErrorMessage", () => {
|
||||||
|
it("อ่านได้ทั้ง plain text และ JSON problem details และว่างเปล่า", async () => {
|
||||||
|
// mock ไม่ใช่ Response จริง (jsdom ไม่มี) — duck-typing ครบตามที่ service ใช้
|
||||||
|
expect(
|
||||||
|
await readErrorMessage(
|
||||||
|
mockResponse({ status: 400, text: "bad request ตรงๆ" }) as unknown as Response,
|
||||||
|
"fallback"
|
||||||
|
)
|
||||||
|
).toBe("bad request ตรงๆ");
|
||||||
|
expect(
|
||||||
|
await readErrorMessage(
|
||||||
|
mockResponse({ status: 400, body: { detail: "จาก JSON" } }) as unknown as Response,
|
||||||
|
"fallback"
|
||||||
|
)
|
||||||
|
).toBe("จาก JSON");
|
||||||
|
expect(
|
||||||
|
await readErrorMessage(mockResponse({ status: 500 }) as unknown as Response, "fallback")
|
||||||
|
).toBe("fallback");
|
||||||
|
});
|
||||||
|
});
|
||||||
119
addin/src/services/apiClient.ts
Normal file
119
addin/src/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { config } from "../config";
|
||||||
|
import * as authService from "./authService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
|
||||||
|
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
|
||||||
|
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthRequiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiRequestOptions {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(path: string): string {
|
||||||
|
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(options.headers ?? {});
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
return fetch(toUrl(path), {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body: options.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
|
||||||
|
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
|
||||||
|
*/
|
||||||
|
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||||
|
const accessToken = await authService.getAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await doFetch(path, options, accessToken);
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshedToken = await authService.refreshAccessToken();
|
||||||
|
if (!refreshedToken) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
response = await doFetch(path, options, refreshedToken);
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new AuthRequiredError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||||
|
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const text = (await response.text()).trim();
|
||||||
|
if (!text) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
||||||
|
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
||||||
|
if (typeof detail === "string" && detail) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// plain text — ใช้ตรงๆ
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
||||||
|
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
||||||
|
const response = await apiFetch(path);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
||||||
|
export async function apiSendExpectJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
fallbackError: string
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await apiFetch(path, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
283
addin/src/services/authService.test.ts
Normal file
283
addin/src/services/authService.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||||
|
|
||||||
|
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
|
||||||
|
|
||||||
|
function base64Url(buffer: Buffer): string {
|
||||||
|
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfficeDialogMock {
|
||||||
|
displayDialogAsync: jest.Mock;
|
||||||
|
dialog: { close: jest.Mock };
|
||||||
|
capturedUrl: () => string;
|
||||||
|
sendMessage: (payload: unknown) => void;
|
||||||
|
sendDialogEvent: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock Office.context.ui.displayDialogAsync + dialog event handlers (FR-8 flow ผ่าน Dialog API) */
|
||||||
|
function installOfficeDialogMock(): OfficeDialogMock {
|
||||||
|
const globalAny = globalThis as unknown as Record<string, unknown>;
|
||||||
|
const messageHandlers: Array<(args: unknown) => void> = [];
|
||||||
|
const eventHandlers: Array<(args: unknown) => void> = [];
|
||||||
|
let capturedAuthUrl = "";
|
||||||
|
|
||||||
|
const dialog = {
|
||||||
|
close: jest.fn(),
|
||||||
|
addEventHandler: (eventType: string, handler: (args: unknown) => void) => {
|
||||||
|
(eventType === "dialogMessageReceived" ? messageHandlers : eventHandlers).push(handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayDialogAsync = jest.fn(
|
||||||
|
(startAddress: string, _options: unknown, callback: (result: unknown) => void) => {
|
||||||
|
capturedAuthUrl = startAddress;
|
||||||
|
callback({ status: "succeeded", value: dialog });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
globalAny.Office = {
|
||||||
|
EventType: {
|
||||||
|
DialogMessageReceived: "dialogMessageReceived",
|
||||||
|
DialogEventReceived: "dialogEventReceived",
|
||||||
|
},
|
||||||
|
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||||
|
context: { ui: { displayDialogAsync } },
|
||||||
|
} as unknown as typeof Office;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayDialogAsync,
|
||||||
|
dialog: dialog as OfficeDialogMock["dialog"],
|
||||||
|
capturedUrl: () => capturedAuthUrl,
|
||||||
|
sendMessage: (payload: unknown) => {
|
||||||
|
messageHandlers.forEach((handler) => handler({ message: JSON.stringify(payload) }));
|
||||||
|
},
|
||||||
|
sendDialogEvent: () => {
|
||||||
|
eventHandlers.forEach((handler) => handler({ error: 12006 }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** รอจนกว่า login จะเรียก displayDialogAsync จริง (ต้องผ่าน crypto.subtle.digest ก่อน — ในเครื่องโหลดอาจช้ากว่า setTimeout(0)) */
|
||||||
|
async function waitForDialog(office: OfficeDialogMock): Promise<void> {
|
||||||
|
for (
|
||||||
|
let attempt = 0;
|
||||||
|
attempt < 200 && office.displayDialogAsync.mock.calls.length === 0;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
expect(office.displayDialogAsync).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authService.signOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
authService.signOut(); // เคลียร์ auto-refresh timer กัน handle ค้าง
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PKCE", () => {
|
||||||
|
it("code_verifier ยาว 43–128 chars และอยู่ในชุดตัวอักษร base64url ที่ RFC 7636 อนุญาต", () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const verifier = authService.createCodeVerifier();
|
||||||
|
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
||||||
|
expect(verifier.length).toBeLessThanOrEqual(128);
|
||||||
|
expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/);
|
||||||
|
expect(verifier).not.toContain("=");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("code_challenge = BASE64URL(SHA256(verifier)) — ตรวจกับ test vector ของ RFC 7636 appendix B", async () => {
|
||||||
|
// vector จาก RFC 7636 เอง
|
||||||
|
const challenge = await authService.createS256Challenge(
|
||||||
|
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
);
|
||||||
|
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("code_challenge ของ verifier สุ่มตรงกับที่คำนวณเองด้วย node crypto", async () => {
|
||||||
|
const verifier = authService.createCodeVerifier();
|
||||||
|
const expected = base64Url(createHash("sha256").update(verifier).digest());
|
||||||
|
await expect(authService.createS256Challenge(verifier)).resolves.toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAuthorizationUrl", () => {
|
||||||
|
it("ประกอบ authorization URL ครบทุกพารามิเตอร์ (client_id/redirect/response_type/scope/state/PKCE)", () => {
|
||||||
|
const url = new URL(
|
||||||
|
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(url.origin).toBe("http://localhost:8080");
|
||||||
|
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
||||||
|
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
||||||
|
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
||||||
|
expect(url.searchParams.get("response_type")).toBe("code");
|
||||||
|
expect(url.searchParams.get("scope")).toBe("openid");
|
||||||
|
expect(url.searchParams.get("state")).toBe("the-state");
|
||||||
|
expect(url.searchParams.get("code_challenge")).toBe("the-challenge");
|
||||||
|
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeJwtExpiry", () => {
|
||||||
|
it("อ่าน exp จาก JWT payload เป็น ms epoch", () => {
|
||||||
|
const expiresAt = Date.now() + 300_000;
|
||||||
|
expect(authService.decodeJwtExpiry(makeFakeJwt(expiresAt))).toBe(
|
||||||
|
Math.floor(expiresAt / 1000) * 1000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("token ไม่ใช่รูป JWT → null", () => {
|
||||||
|
expect(authService.decodeJwtExpiry("not-a-jwt")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login (Office Dialog + token exchange)", () => {
|
||||||
|
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
||||||
|
const office = installOfficeDialogMock();
|
||||||
|
let exchangedBody: URLSearchParams | null = null;
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
||||||
|
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
||||||
|
exchangedBody = new URLSearchParams(String(init?.body));
|
||||||
|
return mockResponse({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
access_token: makeFakeJwt(Date.now() + 300_000),
|
||||||
|
refresh_token: "refresh-1",
|
||||||
|
expires_in: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginPromise = authService.login();
|
||||||
|
await waitForDialog(office);
|
||||||
|
|
||||||
|
// authorization URL ที่ส่งเข้า dialog
|
||||||
|
const authUrl = new URL(office.capturedUrl());
|
||||||
|
const challenge = authUrl.searchParams.get("code_challenge") ?? "";
|
||||||
|
const state = authUrl.searchParams.get("state") ?? "";
|
||||||
|
expect(challenge).not.toBe("");
|
||||||
|
expect(state).not.toBe("");
|
||||||
|
|
||||||
|
office.sendMessage({ code: "the-auth-code", state });
|
||||||
|
await expect(loginPromise).resolves.toEqual({ ok: true });
|
||||||
|
expect(authService.isTokenValid()).toBe(true);
|
||||||
|
expect(office.dialog.close).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
||||||
|
const verifier = exchangedBody?.get("code_verifier") ?? "";
|
||||||
|
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
|
||||||
|
expect(exchangedBody?.get("code")).toBe("the-auth-code");
|
||||||
|
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
|
||||||
|
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state ไม่ตรง (CSRF) → login fail และไม่ยิง token endpoint", async () => {
|
||||||
|
const office = installOfficeDialogMock();
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
|
||||||
|
const loginPromise = authService.login();
|
||||||
|
await waitForDialog(office);
|
||||||
|
office.sendMessage({ code: "code", state: "tampered-state" });
|
||||||
|
|
||||||
|
const result = await loginPromise;
|
||||||
|
expect(result).toEqual({ ok: false, error: expect.stringContaining("state") });
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(authService.isTokenValid()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ผู้ใช้ปิด dialog เอง (DialogEventReceived) → คืน error แบบ graceful", async () => {
|
||||||
|
const office = installOfficeDialogMock();
|
||||||
|
installFetchMock();
|
||||||
|
|
||||||
|
const loginPromise = authService.login();
|
||||||
|
await waitForDialog(office);
|
||||||
|
office.sendDialogEvent();
|
||||||
|
|
||||||
|
const result = await loginPromise;
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(authService.isTokenValid()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Keycloak ปฏิเสธ (error ใน callback) → ok:false พร้อมชื่อ error", async () => {
|
||||||
|
const office = installOfficeDialogMock();
|
||||||
|
installFetchMock();
|
||||||
|
|
||||||
|
const loginPromise = authService.login();
|
||||||
|
await waitForDialog(office);
|
||||||
|
office.sendMessage({ error: "access_denied", errorDescription: "user cancelled" });
|
||||||
|
|
||||||
|
const result = await loginPromise;
|
||||||
|
expect(result).toEqual({ ok: false, error: expect.stringContaining("access_denied") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("เรียก login ซ้อนพร้อมกัน → เปิด dialog ครั้งเดียว ได้ผลเดียวกันทั้งสอง promise", async () => {
|
||||||
|
const office = installOfficeDialogMock();
|
||||||
|
installFetchMock(async () =>
|
||||||
|
mockResponse({
|
||||||
|
status: 200,
|
||||||
|
body: { access_token: makeFakeJwt(Date.now() + 300_000), expires_in: 300 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = authService.login();
|
||||||
|
const second = authService.login();
|
||||||
|
await waitForDialog(office);
|
||||||
|
|
||||||
|
const state = new URL(office.capturedUrl()).searchParams.get("state") ?? "";
|
||||||
|
office.sendMessage({ code: "code", state });
|
||||||
|
|
||||||
|
await expect(first).resolves.toEqual({ ok: true });
|
||||||
|
await expect(second).resolves.toEqual({ ok: true });
|
||||||
|
expect(office.displayDialogAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("silent refresh (AC-9)", () => {
|
||||||
|
it("access token หมดอายุ + refresh token ใช้ได้ → getAccessToken refresh เงียบๆ แล้วคืน token ใหม่", async () => {
|
||||||
|
authService.setSessionForTest("expired-access-token", "refresh-old", Date.now() - 60_000);
|
||||||
|
const newToken = makeFakeJwt(Date.now() + 600_000);
|
||||||
|
const fetchMock = installFetchMock(async () =>
|
||||||
|
mockResponse({
|
||||||
|
status: 200,
|
||||||
|
body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 600 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await authService.getAccessToken();
|
||||||
|
|
||||||
|
expect(token).toBe(newToken);
|
||||||
|
expect(authService.isTokenValid()).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = new URLSearchParams(String((fetchMock.mock.calls[0][1] as { body: string }).body));
|
||||||
|
expect(body.get("grant_type")).toBe("refresh_token");
|
||||||
|
expect(body.get("refresh_token")).toBe("refresh-old");
|
||||||
|
expect(body.get("client_id")).toBe("word-addin-officejs");
|
||||||
|
// Keycloak หมุน refresh token — เก็บอันใหม่ไว้ใช้รอบหน้า
|
||||||
|
expect(authService.hasRefreshToken()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh token ตาย/ถูก revoke → getAccessToken คืน null ไม่ throw และล้าง session", async () => {
|
||||||
|
authService.setSessionForTest("expired-access-token", "refresh-dead", Date.now() - 60_000);
|
||||||
|
const fetchMock = installFetchMock(async () =>
|
||||||
|
mockResponse({ status: 400, text: "invalid_grant" })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
||||||
|
expect(authService.isTokenValid()).toBe(false);
|
||||||
|
expect(authService.hasRefreshToken()).toBe(false);
|
||||||
|
|
||||||
|
// เรียกซ้ำหลัง session ถูกล้าง → null ทันทีโดยไม่ยิง fetch เพิ่ม
|
||||||
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่เคย login เลย → isTokenValid false, getAccessToken null", async () => {
|
||||||
|
expect(authService.isTokenValid()).toBe(false);
|
||||||
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
390
addin/src/services/authService.ts
Normal file
390
addin/src/services/authService.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
/* global Office */
|
||||||
|
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-8 ฝั่ง client — OIDC Authorization Code + PKCE (S256) ผ่าน Office Dialog API
|
||||||
|
*
|
||||||
|
* กติกาสำคัญ:
|
||||||
|
* - task pane รันใน iframe → ห้าม redirect ไป Keycloak ตรงๆ ต้องเปิด dialog (displayDialogAsync)
|
||||||
|
* แล้วรับผลกลับทาง messageParent
|
||||||
|
* - token เก็บใน JS variable ของ task pane session เท่านั้น — ห้าม localStorage
|
||||||
|
* - refresh token ตาย/ถูก revoke → คืน null / { ok: false } แบบ graceful ไม่ปล่อย error ค้าง
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ถือว่า token หมดอายุก่อนจริง 30 วินาที กันแข่งกับนาฬิกา backend */
|
||||||
|
const EXPIRY_SKEW_MS = 30_000;
|
||||||
|
/** auto-refresh เช็คทุก 1 นาที และรีเฟรชล่วงหน้าเมื่อเหลืออายุ < 2 นาที */
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const AUTO_REFRESH_BEFORE_EXPIRY_MS = 120_000;
|
||||||
|
|
||||||
|
export type LoginResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
interface KeycloakTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenState {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
/** ms epoch ที่ access token หมดอายุจริง (ไม่หัก skew) */
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenState: TokenState = { accessToken: null, refreshToken: null, expiresAt: 0 };
|
||||||
|
let pendingLogin: Promise<LoginResult> | null = null;
|
||||||
|
let refreshInFlight: Promise<string | null> | null = null;
|
||||||
|
let autoRefreshTimer: number | null = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PKCE helpers (แยกเป็นฟังก์ชัน export เพื่อ unit test ได้)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** base64url ไม่มี padding — ใช้ทั้ง verifier และ challenge */
|
||||||
|
function base64UrlEncode(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** code_verifier ตาม RFC 7636: random 43–128 chars — 48 bytes → 64 chars base64url */
|
||||||
|
export function createCodeVerifier(): string {
|
||||||
|
const bytes = new Uint8Array(48);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** code_challenge = BASE64URL(SHA256(verifier)) — method S256 */
|
||||||
|
export async function createS256Challenge(verifier: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
||||||
|
return base64UrlEncode(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** state กัน CSRF — สุ่มและเทียบตอนรับ code กลับ */
|
||||||
|
function createState(): string {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** authorization URL เต็มสำหรับเปิดใน Office Dialog */
|
||||||
|
export function buildAuthorizationUrl(params: { codeChallenge: string; state: string }): string {
|
||||||
|
const url = new URL(`${config.keycloak.authority}/protocol/openid-connect/auth`);
|
||||||
|
url.searchParams.set("client_id", config.keycloak.clientId);
|
||||||
|
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", "openid");
|
||||||
|
url.searchParams.set("state", params.state);
|
||||||
|
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dialog (ของจริงเรียก Office.context.ui — mock ได้ใน test)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DialogResult =
|
||||||
|
| { kind: "code"; code: string; state: string }
|
||||||
|
| { kind: "oauth-error"; error: string; description?: string }
|
||||||
|
| { kind: "closed" }
|
||||||
|
| { kind: "failed"; error: string };
|
||||||
|
|
||||||
|
interface CallbackMessage {
|
||||||
|
code?: string;
|
||||||
|
state?: string;
|
||||||
|
error?: string;
|
||||||
|
errorDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuthDialog(url: string): Promise<DialogResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// ตาม spec: dialog สูง 60 / กว้าง 30 เปอร์เซ็นต์ของหน้าจอ
|
||||||
|
Office.context.ui.displayDialogAsync(url, { height: 60, width: 30 }, (asyncResult) => {
|
||||||
|
if (asyncResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve({
|
||||||
|
kind: "failed",
|
||||||
|
error: asyncResult.error?.message || "เปิดหน้า login ไม่สำเร็จ",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = asyncResult.value;
|
||||||
|
let settled = false;
|
||||||
|
const finish = (result: DialogResult) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
dialog.close();
|
||||||
|
} catch {
|
||||||
|
// ปิดไปแล้ว — ไม่เป็นไร
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
// message กลับจาก auth-callback.html (JSON string)
|
||||||
|
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (args) => {
|
||||||
|
const message = (args as { message?: string }).message ?? "";
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message) as CallbackMessage;
|
||||||
|
if (payload.error) {
|
||||||
|
finish({
|
||||||
|
kind: "oauth-error",
|
||||||
|
error: payload.error,
|
||||||
|
description: payload.errorDescription,
|
||||||
|
});
|
||||||
|
} else if (typeof payload.code === "string" && typeof payload.state === "string") {
|
||||||
|
finish({ kind: "code", code: payload.code, state: payload.state });
|
||||||
|
} else {
|
||||||
|
finish({ kind: "closed" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish({ kind: "closed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 12006 = ผู้ใช้ปิด dialog เอง, รวมถึง load ไม่สำเร็จทั้งหลาย → จบ flow แบบ "closed"
|
||||||
|
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
|
||||||
|
finish({ kind: "closed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function requestTokens(body: URLSearchParams): Promise<KeycloakTokenResponse> {
|
||||||
|
const response = await fetch(`${config.keycloak.authority}/protocol/openid-connect/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`token endpoint ตอบ ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as KeycloakTokenResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
verifier: string
|
||||||
|
): Promise<KeycloakTokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: config.keycloak.clientId,
|
||||||
|
code,
|
||||||
|
redirect_uri: config.redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
return requestTokens(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JWT helper (อ่าน exp เท่านั้น — ไม่ใช่การ verify)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function base64UrlDecode(segment: string): string {
|
||||||
|
const base64 = segment
|
||||||
|
.replace(/-/g, "+")
|
||||||
|
.replace(/_/g, "/")
|
||||||
|
.padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** คืน ms epoch ที่ token หมดอายุ จาก claim `exp` — parse ไม่ได้คืน null */
|
||||||
|
export function decodeJwtExpiry(token: string): number | null {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(base64UrlDecode(parts[1])) as { exp?: unknown };
|
||||||
|
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State transitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function applyTokenResponse(tokens: KeycloakTokenResponse): void {
|
||||||
|
tokenState = {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token ?? null,
|
||||||
|
// เชื่อ exp จากตัว token เอง (fallback: expires_in เผื่อ Keycloak ไม่ใส่ exp)
|
||||||
|
expiresAt: decodeJwtExpiry(tokens.access_token) ?? Date.now() + tokens.expires_in * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens(): void {
|
||||||
|
tokenState = { accessToken: null, refreshToken: null, expiresAt: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** มี access token ที่ยังไม่หมดอายุใน memory หรือไม่ (FR-1 ใช้ตัดสินหน้า sign-in) */
|
||||||
|
export function isTokenValid(): boolean {
|
||||||
|
return tokenState.accessToken !== null && Date.now() < tokenState.expiresAt - EXPIRY_SKEW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** มี refresh token ให้ยืด session ต่อได้หรือเปล่า */
|
||||||
|
export function hasRefreshToken(): boolean {
|
||||||
|
return tokenState.refreshToken !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* token สำหรับ request ถัดไป — หมดอายุแล้วมี refresh token ก็ silent refresh ให้ก่อน
|
||||||
|
* คืน null เมื่อต้อง login ใหม่ (ไม่ throw — UI เปลี่ยนเป็นหน้า sign-in)
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
if (isTokenValid()) {
|
||||||
|
return tokenState.accessToken;
|
||||||
|
}
|
||||||
|
if (hasRefreshToken()) {
|
||||||
|
return refreshAccessToken();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* refresh ด้วย refresh_token — เรียกซ้ำระหว่าง refresh ครั้งเดียวกันจะแชร์ promise เดียว
|
||||||
|
* fail (token ตาย/ถูก revoke/network) → เคลียร์ session แล้วคืน null แบบ graceful
|
||||||
|
*/
|
||||||
|
export function refreshAccessToken(): Promise<string | null> {
|
||||||
|
if (refreshInFlight) {
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
const refresh = async (): Promise<string | null> => {
|
||||||
|
const refreshToken = tokenState.refreshToken;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tokens = await requestTokens(
|
||||||
|
new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: config.keycloak.clientId,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
applyTokenResponse(tokens);
|
||||||
|
return tokenState.accessToken;
|
||||||
|
} catch {
|
||||||
|
// refresh token ใช้ไม่ได้อีก → ต้อง login ใหม่ แต่ห้ามโยน error ค้าง (AC-9)
|
||||||
|
clearTokens();
|
||||||
|
stopAutoRefresh();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refreshInFlight = refresh().finally(() => {
|
||||||
|
refreshInFlight = null;
|
||||||
|
});
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** เดิน login flow เต็ม (dialog + แลก token) — กันเรียกซ้อนพร้อมกันด้วย promise เดียว */
|
||||||
|
export async function login(): Promise<LoginResult> {
|
||||||
|
if (pendingLogin) {
|
||||||
|
return pendingLogin;
|
||||||
|
}
|
||||||
|
const flow = (async (): Promise<LoginResult> => {
|
||||||
|
const verifier = createCodeVerifier();
|
||||||
|
const codeChallenge = await createS256Challenge(verifier);
|
||||||
|
const state = createState();
|
||||||
|
const authUrl = buildAuthorizationUrl({ codeChallenge, state });
|
||||||
|
|
||||||
|
const result = await openAuthDialog(authUrl);
|
||||||
|
switch (result.kind) {
|
||||||
|
case "code":
|
||||||
|
if (result.state !== state) {
|
||||||
|
// ป้องกัน CSRF — state ไม่ตรงถือว่าเป็น response ปลอม
|
||||||
|
return { ok: false, error: "การ login ถูกตีกลับ (state ไม่ถูกต้อง) — ลองอีกครั้ง" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyTokenResponse(await exchangeCodeForTokens(result.code, verifier));
|
||||||
|
startAutoRefresh();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: getErrorMessage(error, "แลก token จาก Keycloak ไม่สำเร็จ") };
|
||||||
|
}
|
||||||
|
case "oauth-error":
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Keycloak ปฏิเสธการ login: ${result.error}${result.description ? ` (${result.description})` : ""}`,
|
||||||
|
};
|
||||||
|
case "closed":
|
||||||
|
return { ok: false, error: "ยกเลิกการ login" };
|
||||||
|
case "failed":
|
||||||
|
return { ok: false, error: result.error };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
pendingLogin = flow;
|
||||||
|
try {
|
||||||
|
return await flow;
|
||||||
|
} finally {
|
||||||
|
pendingLogin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ล้าง session (ใช้ตอน UI สั่ง sign out หรือ 401 ซ้ำแล้วเลือกกลับหน้า sign-in) */
|
||||||
|
export function signOut(): void {
|
||||||
|
clearTokens();
|
||||||
|
stopAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* silent refresh ล่วงหน้า — เริ่มหลัง login สำเร็จ
|
||||||
|
* ตัวจับเวลาเช็คทุกนาที ถ้าเหลืออายุ < 2 นาทีก็ refresh เอง (ผู้ใช้ไม่รู้สึก — AC-9)
|
||||||
|
*/
|
||||||
|
export function startAutoRefresh(): void {
|
||||||
|
if (autoRefreshTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoRefreshTimer = setInterval(() => {
|
||||||
|
if (
|
||||||
|
tokenState.refreshToken &&
|
||||||
|
Date.now() >= tokenState.expiresAt - AUTO_REFRESH_BEFORE_EXPIRY_MS
|
||||||
|
) {
|
||||||
|
void refreshAccessToken();
|
||||||
|
}
|
||||||
|
}, AUTO_REFRESH_INTERVAL_MS) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAutoRefresh(): void {
|
||||||
|
if (autoRefreshTimer !== null) {
|
||||||
|
clearInterval(autoRefreshTimer);
|
||||||
|
autoRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// สำหรับ unit test — seed session โดยไม่ต้องเดิน dialog จริง
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** test เท่านั้น: ใส่ token state ตรงๆ (access token เป็น JWT ปลอมก็ได้ — เอาแค่ exp) */
|
||||||
|
export function setSessionForTest(
|
||||||
|
accessToken: string | null,
|
||||||
|
refreshToken: string | null,
|
||||||
|
expiresAt: number
|
||||||
|
): void {
|
||||||
|
tokenState = { accessToken, refreshToken, expiresAt };
|
||||||
|
}
|
||||||
98
addin/src/services/fileService.test.ts
Normal file
98
addin/src/services/fileService.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService";
|
||||||
|
|
||||||
|
interface FileMockState {
|
||||||
|
getFileCalls: number;
|
||||||
|
requestedSlices: number[];
|
||||||
|
closeCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
|
||||||
|
function installOfficeFileMock(
|
||||||
|
slices: Uint8Array[],
|
||||||
|
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {}
|
||||||
|
): FileMockState {
|
||||||
|
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
||||||
|
|
||||||
|
const file = {
|
||||||
|
sliceCount: slices.length,
|
||||||
|
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
|
||||||
|
state.requestedSlices.push(index);
|
||||||
|
if (options.failSliceIndex === index) {
|
||||||
|
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
|
||||||
|
} else {
|
||||||
|
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่
|
||||||
|
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer;
|
||||||
|
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeAsync: (callback?: (result: unknown) => void) => {
|
||||||
|
state.closeCalls++;
|
||||||
|
callback?.({ status: options.failClose ? "failed" : "succeeded" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||||
|
FileType: { Compressed: "compressed", Text: "text" },
|
||||||
|
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||||
|
context: {
|
||||||
|
document: {
|
||||||
|
getFileAsync: (
|
||||||
|
_fileType: unknown,
|
||||||
|
_options: unknown,
|
||||||
|
callback: (result: unknown) => void
|
||||||
|
) => {
|
||||||
|
state.getFileCalls++;
|
||||||
|
callback({ status: "succeeded", value: file });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes(...values: number[]): Uint8Array {
|
||||||
|
return new Uint8Array(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getCurrentDocumentFile", () => {
|
||||||
|
it("ประกอบ slice หลากขนาดเป็น Blob เดียว อ่านครบทุก slice ตามลำดับ และ closeAsync หนึ่งครั้ง", async () => {
|
||||||
|
const state = installOfficeFileMock([
|
||||||
|
bytes(1, 2, 3),
|
||||||
|
bytes(4, 5),
|
||||||
|
new Uint8Array(2048).fill(9),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blob = await getCurrentDocumentFile();
|
||||||
|
|
||||||
|
expect(blob.type).toBe(DOCX_CONTENT_TYPE);
|
||||||
|
expect(blob.size).toBe(3 + 2 + 2048);
|
||||||
|
expect(state.requestedSlices).toEqual([0, 1, 2]);
|
||||||
|
expect(state.getFileCalls).toBe(1);
|
||||||
|
expect(state.closeCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slice มาเป็น typed array view (ไม่ใช่ ArrayBuffer) ก็ประกอบได้", async () => {
|
||||||
|
const state = installOfficeFileMock([bytes(10, 20), bytes(30)], { sliceDataForm: "view" });
|
||||||
|
|
||||||
|
const blob = await getCurrentDocumentFile();
|
||||||
|
|
||||||
|
expect(blob.size).toBe(3);
|
||||||
|
expect(state.closeCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
|
||||||
|
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
|
||||||
|
|
||||||
|
await expect(getCurrentDocumentFile()).rejects.toThrow("อ่าน slice 1 พัง (mock)");
|
||||||
|
expect(state.closeCalls).toBe(1);
|
||||||
|
expect(state.requestedSlices).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeAsync fail เอง → ไม่ทำ flow พัง (blob ยังคืนมา)", async () => {
|
||||||
|
installOfficeFileMock([bytes(7, 8, 9)], { failClose: true });
|
||||||
|
|
||||||
|
const blob = await getCurrentDocumentFile();
|
||||||
|
expect(blob.size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
addin/src/services/fileService.ts
Normal file
90
addin/src/services/fileService.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/* global Office, console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
|
||||||
|
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DOCX_CONTENT_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
||||||
|
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
|
||||||
|
const SLICE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
|
||||||
|
return new Error(result.error?.message || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileAsync(): Promise<Office.File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
Office.context.document.getFileAsync(
|
||||||
|
Office.FileType.Compressed,
|
||||||
|
{ sliceSize: SLICE_SIZE },
|
||||||
|
(result) => {
|
||||||
|
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve(result.value);
|
||||||
|
} else {
|
||||||
|
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */
|
||||||
|
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
file.getSliceAsync(sliceIndex, (result) => {
|
||||||
|
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
|
const data: unknown = result.value.data;
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
resolve(data);
|
||||||
|
} else if (ArrayBuffer.isView(data)) {
|
||||||
|
const view = data as ArrayBufferView;
|
||||||
|
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength));
|
||||||
|
} else {
|
||||||
|
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
|
||||||
|
function closeFileQuietly(file: Office.File): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
file.closeAsync(() => resolve());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("closeAsync failed:", error);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
|
||||||
|
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
|
||||||
|
*/
|
||||||
|
export async function getCurrentDocumentFile(): Promise<Blob> {
|
||||||
|
const file = await getFileAsync();
|
||||||
|
try {
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
for (let index = 0; index < file.sliceCount; index++) {
|
||||||
|
chunks.push(await getSliceAsync(file, index));
|
||||||
|
}
|
||||||
|
return new Blob(chunks, { type: DOCX_CONTENT_TYPE });
|
||||||
|
} finally {
|
||||||
|
await closeFileQuietly(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
addin/src/services/metadataService.test.ts
Normal file
195
addin/src/services/metadataService.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import {
|
||||||
|
ADDIN_SIGNATURE_GUID,
|
||||||
|
buildVersionHistoryXml,
|
||||||
|
CUSTOM_XML_NAMESPACE,
|
||||||
|
isTrackedDocument,
|
||||||
|
readContentControlFields,
|
||||||
|
readTrackedProperties,
|
||||||
|
readVersionHistoryFromXml,
|
||||||
|
stampTrackedProperties,
|
||||||
|
writeVersionHistoryToXml,
|
||||||
|
XmlVersionEntry,
|
||||||
|
} from "./metadataService";
|
||||||
|
import { installWordMock } from "../test/wordMock";
|
||||||
|
|
||||||
|
describe("readTrackedProperties", () => {
|
||||||
|
it("อ่านค่าได้ครบทั้ง 4 properties เมื่อมีทั้งหมด", async () => {
|
||||||
|
installWordMock({
|
||||||
|
properties: {
|
||||||
|
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||||
|
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
LastVersionId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
LastVersionTimestamp: "2026-08-19T10:00:00Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readTrackedProperties()).resolves.toEqual({
|
||||||
|
savedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||||
|
documentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
lastVersionId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
lastVersionTimestamp: "2026-08-19T10:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("property ที่ไม่มีในเอกสารเป็น undefined ไม่ throw", async () => {
|
||||||
|
installWordMock({ properties: { UnrelatedProperty: "x" } });
|
||||||
|
|
||||||
|
const properties = await readTrackedProperties();
|
||||||
|
expect(properties.savedByAddIn).toBeUndefined();
|
||||||
|
expect(properties.documentTrackingId).toBeUndefined();
|
||||||
|
expect(properties.lastVersionId).toBeUndefined();
|
||||||
|
expect(properties.lastVersionTimestamp).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isTrackedDocument (FR-1)", () => {
|
||||||
|
it("true เมื่อ SavedByAddIn ตรง ADDIN_SIGNATURE_GUID", async () => {
|
||||||
|
installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID } });
|
||||||
|
await expect(isTrackedDocument()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("false เมื่อค่าไม่ตรง หรือไม่มี property", async () => {
|
||||||
|
installWordMock({ properties: { SavedByAddIn: "OTHER-ADDIN-GUID" } });
|
||||||
|
await expect(isTrackedDocument()).resolves.toBe(false);
|
||||||
|
|
||||||
|
installWordMock({ properties: {} });
|
||||||
|
await expect(isTrackedDocument()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stampTrackedProperties (FR-2 ขั้นตอนที่ 2)", () => {
|
||||||
|
it("stamp ครบ 4 properties ด้วย add() ทับค่าเดิมได้", async () => {
|
||||||
|
const state = installWordMock({
|
||||||
|
properties: { SavedByAddIn: "OTHER-ADDIN-GUID", DocumentTrackingId: "old-tracking-id" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await stampTrackedProperties({
|
||||||
|
documentTrackingId: "new-tracking-id",
|
||||||
|
lastVersionId: "version-2",
|
||||||
|
lastVersionTimestamp: "2026-08-19T11:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.propertyAdds).toEqual([
|
||||||
|
{ key: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID },
|
||||||
|
{ key: "DocumentTrackingId", value: "new-tracking-id" },
|
||||||
|
{ key: "LastVersionId", value: "version-2" },
|
||||||
|
{ key: "LastVersionTimestamp", value: "2026-08-19T11:00:00Z" },
|
||||||
|
]);
|
||||||
|
// add() ของ property ที่มีอยู่ = update ค่าในเอกสารจริง
|
||||||
|
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
|
||||||
|
expect(state.properties.DocumentTrackingId).toBe("new-tracking-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่ส่งค่าใดมา = stamp เฉพาะ SavedByAddIn", async () => {
|
||||||
|
const state = installWordMock({});
|
||||||
|
|
||||||
|
await stampTrackedProperties({});
|
||||||
|
|
||||||
|
expect(state.propertyAdds).toEqual([{ key: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Custom XML Part (delete-then-add, silent fail)", () => {
|
||||||
|
const entries: XmlVersionEntry[] = [
|
||||||
|
{ id: "version-3", number: 3, timestamp: "2026-08-19T12:00:00Z", user: "tester" },
|
||||||
|
{ id: "version-2", number: 2, timestamp: "2026-08-19T11:00:00Z", user: "other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("writeVersionHistoryToXml ลบ part เดิมทั้งหมดก่อน add ของใหม่ แล้วคืน true", async () => {
|
||||||
|
const state = installWordMock({
|
||||||
|
xmlParts: [
|
||||||
|
buildVersionHistoryXml([{ id: "version-1", number: 1, timestamp: "t", user: "u" }]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(writeVersionHistoryToXml(entries)).resolves.toBe(true);
|
||||||
|
|
||||||
|
// อันเดิมถูกลบ เหลืออันใหม่อันเดียว (อยู่ใน namespace ของเรา)
|
||||||
|
expect(state.xmlParts).toHaveLength(1);
|
||||||
|
expect(state.xmlParts[0].namespace).toBe(CUSTOM_XML_NAMESPACE);
|
||||||
|
expect(state.xmlParts[0].xml).toContain('id="version-3"');
|
||||||
|
expect(state.xmlParts[0].xml).toContain('number="3"');
|
||||||
|
expect(state.xmlParts[0].xml).toContain('user="tester"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("เขียนไม่ได้ (known bug บน Mac) → คืน false ไม่ throw (AC-10)", async () => {
|
||||||
|
installWordMock({ failXmlAdd: true });
|
||||||
|
|
||||||
|
await expect(writeVersionHistoryToXml(entries)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readVersionHistoryFromXml parse รายการและเรียงใหม่ → เก่า", async () => {
|
||||||
|
installWordMock({
|
||||||
|
xmlParts: [
|
||||||
|
buildVersionHistoryXml([
|
||||||
|
{ id: "version-1", number: 1, timestamp: "2026-08-19T09:00:00Z", user: "a" },
|
||||||
|
{ id: "version-3", number: 3, timestamp: "2026-08-19T12:00:00Z", user: "b" },
|
||||||
|
{ id: "version-2", number: 2, timestamp: "2026-08-19T11:00:00Z", user: "c" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await readVersionHistoryFromXml();
|
||||||
|
expect(result.map((entry) => entry.number)).toEqual([3, 2, 1]);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: "version-3",
|
||||||
|
number: 3,
|
||||||
|
timestamp: "2026-08-19T12:00:00Z",
|
||||||
|
user: "b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("อ่าน fail → คืน [] ไม่ throw (XML part เป็น secondary เท่านั้น)", async () => {
|
||||||
|
installWordMock({ failXmlRead: true });
|
||||||
|
await expect(readVersionHistoryFromXml()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildVersionHistoryXml escape อักขระพิเศษใน attribute", () => {
|
||||||
|
const xml = buildVersionHistoryXml([
|
||||||
|
{ id: 'a"b', number: 1, timestamp: "<t>&", user: "u'ser" },
|
||||||
|
]);
|
||||||
|
expect(xml).toContain('id="a"b"');
|
||||||
|
expect(xml).toContain('timestamp="<t>&"');
|
||||||
|
expect(xml).toContain('user="u'ser"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readContentControlFields (FR-11)", () => {
|
||||||
|
it("แปลง contentControls เป็น map tag → text", async () => {
|
||||||
|
installWordMock({
|
||||||
|
contentControls: [
|
||||||
|
{ tag: "employee_name", text: "สมชาย ใจดี" },
|
||||||
|
{ tag: "salary", text: "50000" },
|
||||||
|
{ tag: "start_date", text: "2026-08-01" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readContentControlFields()).resolves.toEqual({
|
||||||
|
employee_name: "สมชาย ใจดี",
|
||||||
|
salary: "50000",
|
||||||
|
start_date: "2026-08-01",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag ซ้ำเอาตัวแรกตามลำดับในเอกสาร / tag ว่างข้าม", async () => {
|
||||||
|
installWordMock({
|
||||||
|
contentControls: [
|
||||||
|
{ tag: "position", text: "first" },
|
||||||
|
{ tag: "position", text: "second" },
|
||||||
|
{ tag: "", text: "ไม่มี tag" },
|
||||||
|
{ tag: " ", text: "tag วรรคล้วน" },
|
||||||
|
{ tag: "note", text: "ok" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readContentControlFields()).resolves.toEqual({
|
||||||
|
position: "first",
|
||||||
|
note: "ok",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("เอกสารไม่มี content controls → คืน {} ไม่ error", async () => {
|
||||||
|
installWordMock({ contentControls: [] });
|
||||||
|
await expect(readContentControlFields()).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
addin/src/services/metadataService.ts
Normal file
231
addin/src/services/metadataService.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
/* global Word, console */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
|
||||||
|
* - Custom Document Properties = primary flag ตรวจจับ tracked document
|
||||||
|
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
|
||||||
|
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
|
||||||
|
*
|
||||||
|
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
|
||||||
|
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
|
||||||
|
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
|
||||||
|
|
||||||
|
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
|
||||||
|
|
||||||
|
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
|
||||||
|
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
|
||||||
|
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
|
||||||
|
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
||||||
|
|
||||||
|
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||||
|
export interface TrackedDocumentProperties {
|
||||||
|
savedByAddIn?: string;
|
||||||
|
documentTrackingId?: string;
|
||||||
|
lastVersionId?: string;
|
||||||
|
lastVersionTimestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||||
|
export interface XmlVersionEntry {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
timestamp: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */
|
||||||
|
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||||
|
return Word.run(async (context) => {
|
||||||
|
const properties = context.document.properties.customProperties;
|
||||||
|
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
|
||||||
|
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
|
||||||
|
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||||
|
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||||
|
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||||
|
savedByAddIn.load("value");
|
||||||
|
documentTrackingId.load("value");
|
||||||
|
lastVersionId.load("value");
|
||||||
|
lastVersionTimestamp.load("value");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const read = (property: Word.CustomProperty): string | undefined =>
|
||||||
|
property.isNullObject || property.value === undefined || property.value === null
|
||||||
|
? undefined
|
||||||
|
: String(property.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
savedByAddIn: read(savedByAddIn),
|
||||||
|
documentTrackingId: read(documentTrackingId),
|
||||||
|
lastVersionId: read(lastVersionId),
|
||||||
|
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) */
|
||||||
|
export async function isTrackedDocument(): Promise<boolean> {
|
||||||
|
const properties = await readTrackedProperties();
|
||||||
|
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StampPropertiesInput {
|
||||||
|
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
|
||||||
|
documentTrackingId?: string;
|
||||||
|
lastVersionId?: string;
|
||||||
|
lastVersionTimestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
|
||||||
|
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
|
||||||
|
*/
|
||||||
|
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
const properties = context.document.properties.customProperties;
|
||||||
|
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
|
||||||
|
if (input.documentTrackingId !== undefined) {
|
||||||
|
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
|
||||||
|
}
|
||||||
|
if (input.lastVersionId !== undefined) {
|
||||||
|
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
|
||||||
|
}
|
||||||
|
if (input.lastVersionTimestamp !== undefined) {
|
||||||
|
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
|
||||||
|
export function generateGuid(): string {
|
||||||
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeXmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
|
||||||
|
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
|
||||||
|
const elements = entries
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
|
||||||
|
entry.timestamp
|
||||||
|
)}" user="${escapeXmlAttribute(entry.user)}"/>`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||||
|
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
|
||||||
|
return nodes
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.getAttribute("id") ?? "",
|
||||||
|
number: Number(node.getAttribute("number") ?? Number.NaN),
|
||||||
|
timestamp: node.getAttribute("timestamp") ?? "",
|
||||||
|
user: node.getAttribute("user") ?? "",
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
|
||||||
|
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
|
||||||
|
try {
|
||||||
|
return await Word.run(async (context) => {
|
||||||
|
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||||
|
context.load(scoped);
|
||||||
|
await context.sync();
|
||||||
|
const xmlResults = scoped.items.map((part) => part.getXml());
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const entries: XmlVersionEntry[] = [];
|
||||||
|
for (const result of xmlResults) {
|
||||||
|
entries.push(...parseVersionHistoryXml(result.value));
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
|
||||||
|
return entries;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
|
||||||
|
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
|
||||||
|
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
|
||||||
|
*/
|
||||||
|
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
|
||||||
|
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
|
||||||
|
context.load(scoped);
|
||||||
|
await context.sync();
|
||||||
|
for (const part of scoped.items) {
|
||||||
|
part.delete();
|
||||||
|
}
|
||||||
|
if (scoped.items.length > 0) {
|
||||||
|
await context.sync();
|
||||||
|
}
|
||||||
|
// 2) add ของใหม่
|
||||||
|
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content controls → fields (FR-11)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
|
||||||
|
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
|
||||||
|
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
|
||||||
|
*/
|
||||||
|
export async function readContentControlFields(): Promise<Record<string, string>> {
|
||||||
|
return Word.run(async (context) => {
|
||||||
|
const controls = context.document.contentControls;
|
||||||
|
controls.load("tag, text");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
for (const control of controls.items) {
|
||||||
|
const tag = (control.tag ?? "").trim();
|
||||||
|
if (tag === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
|
||||||
|
fields[tag] = control.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
}
|
||||||
60
addin/src/services/versioningService.ts
Normal file
60
addin/src/services/versioningService.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { config } from "../config";
|
||||||
|
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
|
||||||
|
import { apiFetch, apiGetJson, apiSendExpectJson, ApiError, readErrorMessage } from "./apiClient";
|
||||||
|
import { generateGuid } from "./metadataService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เรียก version API ของ backend (FR-5/6/7 ฝั่ง client)
|
||||||
|
* `fields` = map จาก `readContentControlFields()` ตาม FR-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) */
|
||||||
|
export async function uploadVersion(
|
||||||
|
documentId: string,
|
||||||
|
file: Blob,
|
||||||
|
clientVersionId: string,
|
||||||
|
fields?: Record<string, string>,
|
||||||
|
fileName?: string
|
||||||
|
): Promise<UploadVersionResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, fileName || `${documentId}.docx`);
|
||||||
|
form.append("clientVersionId", clientVersionId);
|
||||||
|
if (fields && Object.keys(fields).length > 0) {
|
||||||
|
form.append("fields", JSON.stringify(fields));
|
||||||
|
}
|
||||||
|
return apiSendExpectJson<UploadVersionResult>(
|
||||||
|
`/api/documents/${documentId}/versions`,
|
||||||
|
{ method: "POST", body: form },
|
||||||
|
"อัปโหลด version ไม่สำเร็จ"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-6 — version history เรียงใหม่ → เก่า */
|
||||||
|
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
|
||||||
|
return apiGetJson<VersionRecord[]>(
|
||||||
|
`/api/documents/${documentId}/versions`,
|
||||||
|
"โหลด version history ไม่สำเร็จ"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
|
||||||
|
export function buildDownloadUrl(documentId: string, versionId: string): string {
|
||||||
|
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
||||||
|
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
||||||
|
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
await readErrorMessage(response, `ดาวน์โหลด version ไม่สำเร็จ (${response.status})`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
|
||||||
|
export function newClientVersionId(): string {
|
||||||
|
return generateGuid();
|
||||||
|
}
|
||||||
16
addin/src/taskpane/auth-callback.html
Normal file
16
addin/src/taskpane/auth-callback.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="th">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||||
|
<title>Sign-in</title>
|
||||||
|
<!-- Office JavaScript API — ต้องโหลดก่อนเรียก messageParent -->
|
||||||
|
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="width: 100%; height: 100%; margin: 0; padding: 16px; font-family: 'Segoe UI', sans-serif; color: #333;">
|
||||||
|
<p id="status">กำลังกลับสู่ add-in…</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
61
addin/src/taskpane/auth-callback.ts
Normal file
61
addin/src/taskpane/auth-callback.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/* global Office, window, URLSearchParams, document */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* หน้า redirect ของ Keycloak — รัน "ใน Office Dialog" ไม่ใช่ task pane (FR-8)
|
||||||
|
* หน้าที่: parse code/state จาก URL → messageParent กลับ task pane → ปิด dialog
|
||||||
|
* ตัวแลก token อยู่ฝั่ง task pane (authService.login) เพราะ verifier ถูก generate ที่นั่น
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CallbackPayload {
|
||||||
|
code?: string;
|
||||||
|
state?: string;
|
||||||
|
error?: string;
|
||||||
|
errorDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text: string): void {
|
||||||
|
const element = document.getElementById("status");
|
||||||
|
if (element) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Office.onReady(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const payload: CallbackPayload = {};
|
||||||
|
|
||||||
|
const error = params.get("error");
|
||||||
|
if (error) {
|
||||||
|
payload.error = error;
|
||||||
|
const description = params.get("error_description");
|
||||||
|
if (description) {
|
||||||
|
payload.errorDescription = description;
|
||||||
|
}
|
||||||
|
setStatus("เข้าสู่ระบบไม่สำเร็จ — กำลังปิดหน้าต่าง…");
|
||||||
|
} else {
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
if (code && state) {
|
||||||
|
payload.code = code;
|
||||||
|
payload.state = state;
|
||||||
|
setStatus("เข้าสู่ระบบสำเร็จ — กำลังกลับสู่ add-in…");
|
||||||
|
} else {
|
||||||
|
// เปิดตรงๆ ไม่ใช่ redirect จาก Keycloak — ไม่มีอะไรจะส่งกลับ
|
||||||
|
payload.error = "invalid_callback";
|
||||||
|
payload.errorDescription = "ไม่พบ code/state ใน URL";
|
||||||
|
setStatus("หน้านี้เปิดโดยตรงไม่ได้ — ต้องเข้าผ่านการ login ของ add-in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Office.context.ui.messageParent(JSON.stringify(payload));
|
||||||
|
} catch (error) {
|
||||||
|
// เช่นเปิดเป็น tab เองนอก dialog — บอกผู้ใช้แทนที่จะ throw
|
||||||
|
setStatus("ไม่สามารถติดต่อกลับ task pane ได้ (หน้านี้ต้องเปิดผ่าน add-in)");
|
||||||
|
console.error("messageParent failed:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ปิด dialog ตัวเองหลังส่ง message กลับแล้ว (task pane ก็จะเรียก dialog.close() อีกชั้น)
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-com
|
||||||
/* global HTMLTextAreaElement */
|
/* global HTMLTextAreaElement */
|
||||||
|
|
||||||
interface TextInsertionProps {
|
interface TextInsertionProps {
|
||||||
insertText: (text: string) => void;
|
insertText: (_text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
|
|
|
||||||
46
addin/src/test/httpMock.ts
Normal file
46
addin/src/test/httpMock.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* Mock response แบบ duck-typing แทน `Response` จริง (jsdom ไม่มี Response/fetch global)
|
||||||
|
* service ที่ถูก test ใช้แค่ ok / status / json() / text() / blob()
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MockHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json: () => Promise<unknown>;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
blob?: () => Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockResponse(
|
||||||
|
options: { status?: number; body?: unknown; text?: string; blob?: Blob } = {}
|
||||||
|
): MockHttpResponse {
|
||||||
|
const status = options.status ?? 200;
|
||||||
|
const text = options.text ?? (options.body !== undefined ? JSON.stringify(options.body) : "");
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => JSON.parse(text),
|
||||||
|
text: async () => text,
|
||||||
|
...(options.blob !== undefined ? { blob: async () => options.blob as Blob } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** แทน globalThis.fetch ด้วย mock — คืนตัวจับ call กลับไปให้ assert */
|
||||||
|
export function installFetchMock(
|
||||||
|
implementation?: (...args: unknown[]) => Promise<MockHttpResponse>
|
||||||
|
): jest.Mock {
|
||||||
|
const fetchMock = jest.fn(
|
||||||
|
implementation ?? (async () => mockResponse({ status: 200, body: {} }))
|
||||||
|
);
|
||||||
|
(globalThis as unknown as Record<string, unknown>).fetch = fetchMock;
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` */
|
||||||
|
export function makeFakeJwt(expiresAtMs: number, subject = "user-1"): string {
|
||||||
|
const encode = (value: string): string =>
|
||||||
|
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||||
|
const payload = encode(JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000) }));
|
||||||
|
return `${header}.${payload}.${encode("fake-signature")}`;
|
||||||
|
}
|
||||||
28
addin/src/test/setup.ts
Normal file
28
addin/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Setup ก่อนรัน test ทุกไฟล์:
|
||||||
|
* - polyfill WebCrypto.subtle / TextEncoder / TextDecoder (jsdom ไม่มี — ดึงจาก node)
|
||||||
|
* - ประกาศค่า DefinePlugin constants ที่ webpack ฉีดให้ตอน build (ค่า dev ตรง webpack.config.js)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { webcrypto } from "crypto";
|
||||||
|
|
||||||
|
const globalAny = globalThis as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!(globalAny.crypto as Crypto | undefined)?.subtle) {
|
||||||
|
// globalThis.crypto ใน jsdom เป็น getter-only (assign ตรงๆ ไม่ได้) แต่ configurable → defineProperty
|
||||||
|
Object.defineProperty(globalThis, "crypto", {
|
||||||
|
value: webcrypto,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalAny.TextEncoder === "undefined") {
|
||||||
|
const util = require("util");
|
||||||
|
globalAny.TextEncoder = util.TextEncoder;
|
||||||
|
globalAny.TextDecoder = util.TextDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalAny.__KEYCLOAK_AUTHORITY__ = "http://localhost:8080/realms/word-versioning";
|
||||||
|
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
|
||||||
|
globalAny.__API_BASE_URL__ = "http://localhost:5075";
|
||||||
116
addin/src/test/wordMock.ts
Normal file
116
addin/src/test/wordMock.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { CUSTOM_XML_NAMESPACE } from "../services/metadataService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock `Word.run` ให้เหมือน Office.js จริงพอให้ metadataService ทำงานได้:
|
||||||
|
* - customProperties: getItemOrNullObject คืน proxy ที่มี isNullObject/value, add() บันทึกลง state
|
||||||
|
* - customXmlParts: getByNamespace/add/delete ใช้ state.xmlParts (string ต่อ part)
|
||||||
|
* - contentControls: items จาก state.contentControls
|
||||||
|
* หมายเหตุ: mock ถือว่าทุก xml part อยู่ใน namespace `urn:myaddin:versioning` ของเรา
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WordMockOptions {
|
||||||
|
properties?: Record<string, string>;
|
||||||
|
contentControls?: Array<{ tag: string; text: string }>;
|
||||||
|
xmlParts?: string[];
|
||||||
|
/** จำลอง known bug บน Mac: add() XML part โดย throw */
|
||||||
|
failXmlAdd?: boolean;
|
||||||
|
/** จำลอง read XML part fail */
|
||||||
|
failXmlRead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordMockState {
|
||||||
|
properties: Record<string, string>;
|
||||||
|
/** ทุกครั้งที่โค้ดเรียก customProperties.add(key, value) */
|
||||||
|
propertyAdds: Array<{ key: string; value: unknown }>;
|
||||||
|
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
|
||||||
|
xmlParts: Array<{ xml: string; namespace: string }>;
|
||||||
|
contentControls: Array<{ tag: string; text: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||||
|
const state: WordMockState = {
|
||||||
|
properties: { ...(options.properties ?? {}) },
|
||||||
|
propertyAdds: [],
|
||||||
|
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
|
||||||
|
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
|
||||||
|
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeProperty = (key: string): unknown => {
|
||||||
|
const exists = Object.prototype.hasOwnProperty.call(state.properties, key);
|
||||||
|
return {
|
||||||
|
key: exists ? key : "",
|
||||||
|
get value() {
|
||||||
|
return exists ? state.properties[key] : undefined;
|
||||||
|
},
|
||||||
|
isNullObject: !exists,
|
||||||
|
load: () => undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePart = (part: { xml: string; namespace: string }): unknown => ({
|
||||||
|
getXml: () => ({ value: part.xml }),
|
||||||
|
delete: () => {
|
||||||
|
const index = state.xmlParts.indexOf(part);
|
||||||
|
if (index >= 0) {
|
||||||
|
state.xmlParts.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customXmlParts = {
|
||||||
|
getByNamespace: (namespaceUri: string) => {
|
||||||
|
if (options.failXmlRead) {
|
||||||
|
throw new Error("getByNamespace failed (mock)");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: state.xmlParts.filter((part) => part.namespace === namespaceUri).map(makePart),
|
||||||
|
load: () => undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
add: (xml: string) => {
|
||||||
|
if (options.failXmlAdd) {
|
||||||
|
throw new Error("custom xml part add failed (mock Mac bug)");
|
||||||
|
}
|
||||||
|
const part = { xml, namespace: CUSTOM_XML_NAMESPACE };
|
||||||
|
state.xmlParts.push(part);
|
||||||
|
return makePart(part);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
document: {
|
||||||
|
properties: {
|
||||||
|
customProperties: {
|
||||||
|
getItemOrNullObject: (key: string) => makeProperty(key),
|
||||||
|
add: (key: string, value: unknown) => {
|
||||||
|
state.propertyAdds.push({ key, value });
|
||||||
|
state.properties[key] = String(value);
|
||||||
|
return makeProperty(key);
|
||||||
|
},
|
||||||
|
load: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
|
||||||
|
customXmlParts,
|
||||||
|
contentControls: {
|
||||||
|
get items() {
|
||||||
|
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
|
||||||
|
},
|
||||||
|
load: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sync: async () => undefined,
|
||||||
|
load: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis as unknown as Record<string, unknown>).Word = {
|
||||||
|
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
|
||||||
|
batch(context as unknown as Word.RequestContext),
|
||||||
|
} as unknown as typeof Word;
|
||||||
|
|
||||||
|
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้
|
||||||
|
expect(CUSTOM_XML_NAMESPACE).toBe("urn:myaddin:versioning");
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,17 @@
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
"lib": [
|
"lib": [
|
||||||
"es7",
|
"es2020",
|
||||||
"dom"
|
"dom",
|
||||||
|
"dom.iterable"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"office-js",
|
||||||
|
"office-runtime",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"jest",
|
||||||
|
"node"
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ const webpack = require("webpack");
|
||||||
const urlDev = "https://localhost:3000/";
|
const urlDev = "https://localhost:3000/";
|
||||||
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
||||||
|
|
||||||
|
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
|
||||||
|
const keycloakAuthority = process.env.KEYCLOAK_AUTHORITY || "http://localhost:8080/realms/word-versioning";
|
||||||
|
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
|
||||||
|
const apiBaseUrl = (process.env.API_BASE_URL || "http://localhost:5075").replace(/\/$/, "");
|
||||||
|
|
||||||
async function getHttpsOptions() {
|
async function getHttpsOptions() {
|
||||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||||
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||||
|
|
@ -24,6 +29,7 @@ module.exports = async (env, options) => {
|
||||||
import: ["./src/taskpane/index.tsx", "./src/taskpane/taskpane.html"],
|
import: ["./src/taskpane/index.tsx", "./src/taskpane/taskpane.html"],
|
||||||
dependOn: "react",
|
dependOn: "react",
|
||||||
},
|
},
|
||||||
|
authCallback: "./src/taskpane/auth-callback.ts",
|
||||||
commands: "./src/commands/commands.ts",
|
commands: "./src/commands/commands.ts",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
@ -66,6 +72,12 @@ module.exports = async (env, options) => {
|
||||||
template: "./src/taskpane/taskpane.html",
|
template: "./src/taskpane/taskpane.html",
|
||||||
chunks: ["polyfill", "taskpane", "react"],
|
chunks: ["polyfill", "taskpane", "react"],
|
||||||
}),
|
}),
|
||||||
|
// หน้า redirect ของ Keycloak ที่รันใน Office Dialog (FR-8) — ต้องอยู่ที่ root ตรง redirect URI
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: "auth-callback.html",
|
||||||
|
template: "./src/taskpane/auth-callback.html",
|
||||||
|
chunks: ["polyfill", "authCallback"],
|
||||||
|
}),
|
||||||
new CopyWebpackPlugin({
|
new CopyWebpackPlugin({
|
||||||
patterns: [
|
patterns: [
|
||||||
{
|
{
|
||||||
|
|
@ -93,6 +105,12 @@ module.exports = async (env, options) => {
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
Promise: ["es6-promise", "Promise"],
|
Promise: ["es6-promise", "Promise"],
|
||||||
}),
|
}),
|
||||||
|
// ค่า config ที่โค้ดอ่านผ่าน src/config.ts — แทนที่ตอน build ด้วยค่าจาก env
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
__KEYCLOAK_AUTHORITY__: JSON.stringify(keycloakAuthority),
|
||||||
|
__KEYCLOAK_CLIENT_ID__: JSON.stringify(keycloakClientId),
|
||||||
|
__API_BASE_URL__: JSON.stringify(apiBaseUrl),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
devServer: {
|
devServer: {
|
||||||
hot: true,
|
hot: true,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||||
|
|
||||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||||
> สถานะ: Phase 0–3 เสร็จ (scaffold + infra + backend core + backend template & generation) — ต่อไป: phase 4–5 (add-in) หรือ phase 6 (web) ทำขนานกันได้
|
> สถานะ: Phase 0–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้
|
||||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -293,31 +293,48 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
|
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
|
||||||
|
|
||||||
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
|
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
|
||||||
- [ ] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
|
- [x] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
|
||||||
- [ ] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
|
- [x] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
|
||||||
- [ ] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
|
- [x] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
|
||||||
- [ ] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
|
- [x] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
|
||||||
- [ ] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
|
- [x] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
|
||||||
- [ ] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
|
- [x] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
|
||||||
- [ ] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
|
- [x] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
|
||||||
- [ ] ป้องกัน CSRF ด้วยการเทียบ `state`
|
- [x] ป้องกัน CSRF ด้วยการเทียบ `state`
|
||||||
|
|
||||||
### 4.2 Metadata (`metadataService.ts`)
|
### 4.2 Metadata (`metadataService.ts`)
|
||||||
- [ ] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
- [x] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||||
- [ ] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
|
- [x] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
|
||||||
- [ ] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
|
- [x] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
|
||||||
- [ ] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
|
- [x] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
|
||||||
- [ ] Constants: `ADDIN_SIGNATURE_GUID`
|
- [x] Constants: `ADDIN_SIGNATURE_GUID`
|
||||||
- [ ] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
|
- [x] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
|
||||||
|
|
||||||
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
|
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
|
||||||
- [ ] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
|
- [x] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
|
||||||
- [ ] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
|
- [x] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
|
||||||
- [ ] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
|
- [x] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
|
||||||
- [ ] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
|
- [x] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
|
||||||
|
|
||||||
### 4.4 Manifest
|
### 4.4 Manifest
|
||||||
- [ ] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
|
- [x] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
|
||||||
|
|
||||||
|
หมายเหตุจากการทำจริง (2026-08-19):
|
||||||
|
- **`customXmlParts` อยู่บน `context.document` ไม่ใช่ `context`** (ตาม @types/office-js — WordApi 1.4) — ใช้ `context.document.customXmlParts.getByNamespace(...)` / `.add(...)`
|
||||||
|
- **Config inject ผ่าน webpack DefinePlugin** (`KEYCLOAK_AUTHORITY` / `KEYCLOAK_CLIENT_ID` / `API_BASE_URL` จาก env ตอน build, default dev = Keycloak 8080 / backend 5075) — อ่านที่ `src/config.ts`; `redirectUri` derive จาก `window.location.origin + "/auth-callback.html"` ให้ตรง realm-export เสมอ
|
||||||
|
- `auth-callback.html` ต้องอยู่ **root ของ dist** ตรง redirect URI — เพิ่ม entry `authCallback` + HtmlWebpackPlugin แยกใน webpack.config.js (template `src/taskpane/auth-callback.html` + logic ใน `auth-callback.ts`, ไม่ใช่ React)
|
||||||
|
- **Jest ใช้ babel-jest** (คอมไพล์ด้วย babel.config.json เดิม) ไม่ใช่ ts-jest — ts-jest@29 peerOptional ไม่รับ @babel/core 8 ของ scaffold; ผลข้างเคียง: test ไม่ type-check ตอนรัน (type ตรวจที่ `npm run build` ผ่าน ts-loader ซึ่งไล่ทั้ง program รวม *.test.ts แล้ว)
|
||||||
|
- jsdom env ไม่มี `Response`/`fetch`/`crypto.subtle` และ `crypto` เป็น getter-only (assign ตรงๆ เงียบ) → `src/test/setup.ts` ต้อง `Object.defineProperty` แล้วดึง webcrypto จาก node; mock HTTP จึงเป็น duck-typing object ไม่ใช่ `Response` จริง (ดู `src/test/httpMock.ts`)
|
||||||
|
- test ที่รอ dialog ต้อง **poll จน `displayDialogAsync` ถูกเรียก** แทน setTimeout(0) — บนเครื่องโหลด `crypto.subtle.digest` อาจยังไม่จบภายใน macrotask เดียว (มิฉะนั้น message หลุดก่อน handler ลงทะเบียน)
|
||||||
|
- **tsconfig เพิ่ม `types` จำกัด** (office-js, office-runtime, react, react-dom, jest, node) — ไม่อย่างนั้น `@types/babel__core` ที่ jest/babel-jest ดึงเข้ามาชนกับ @babel/types v8 จน tsc พัง
|
||||||
|
- **eslint: สร้าง `eslint.config.mjs` ของโปรเจกต์** (office-addin-lint จะใช้แทน config ของมันถ้ามีไฟล์นี้ใน root) เพราะ rule `no-navigational-load` ของ plugin 4.0.10 (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (b) false positive กับ Word properties เช่น `tag` — ปิด rule นี้ + ปิด `no-undef`/`no-redeclare` สำหรับ .ts/.tsx (tsc ตรวจแทนแม่นกว่า)
|
||||||
|
- manifest: element `Requirements` วางระหว่าง `</Hosts>` กับ `<DefaultSettings>` — `npm run validate` ผ่าน และ platform analysis ยังครบ (Word desktop/web/iPad ทุกรุ่นรองรับ WordApi 1.5 + DialogAPI 1.1)
|
||||||
|
- `readTrackedProperties` ใช้ `getItemOrNullObject(...).load("value")` แล้วอ่าน `isNullObject` หลัง sync (null-object proxy ต้องถูก load อย่างน้อย 1 property ค่า isNullObject จึงแม่น)
|
||||||
|
- Services ยังไม่ถูก import เข้า taskpane entry (เป็นงาน Phase 5) — ครั้งแรกที่จะรันจริงใน Word คือตอน wire UI
|
||||||
|
|
||||||
|
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
||||||
|
|
||||||
|
> ✅ ตรวจผ่านจริง (2026-08-19): `npm run build` compiled successfully (0 errors), `npm test` 40/40 ผ่านทั้ง 4 ชุด (authService 14, metadataService 14, apiClient 8, fileService 4 รวมกรณี slice พังกลางทาง→ยัง closeAsync, closeAsync พัง→ยังได้ blob), `npm run lint` ผ่าน, `npm run validate` (manifest) ผ่าน
|
||||||
|
|
||||||
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue