Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word)

การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first

- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
  OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
  — ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
  [AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
  ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
  แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
  best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
  ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
  กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
  (1 round, minors 4 แก้ครบ)

หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-10 13:39:23 +07:00
parent e0558e3cf4
commit ee4d8573ee
21 changed files with 340 additions and 145 deletions

View file

@ -19,7 +19,6 @@ function mount() {
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('useApi', () => ({ apiJson }))
tab = { opener: {}, closed: false, location: { replace }, close }
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
@ -30,7 +29,6 @@ afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.clearAllMocks()
vi.useRealTimers()
})
describe('version open lifecycle', () => {
@ -84,19 +82,16 @@ describe('version open lifecycle', () => {
expect(state.downloadError.value).toBe('popup error')
expect(state.openingVersionId.value).toBeNull()
})
it('offers Word fallback after timeout and removes listeners on unmount', async () => {
it('openVersion (docx) ดาวน์โหลดทันที + ยิง ms-word + ตั้ง notice และเคลียร์ตอน unmount', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const remove = vi.spyOn(window, 'removeEventListener')
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน download-first click จริง
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
const state = mount()
await state.openVersion('d', 'v')
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
vi.advanceTimersByTime(3000)
expect(state.openNotice.value).toBeTruthy()
expect(state.fallbackUrl.value).toBeTruthy()
app.unmount()
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
})
it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
@ -106,10 +101,9 @@ describe('version open lifecycle', () => {
expect(apiJson).not.toHaveBeenCalled()
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb')
vi.advanceTimersByTime(3000)
expect(state.openNotice.value).toBeTruthy()
})
it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => {
it('download-first: ดาวน์โหลดไฟล์ทันทีที่กด (ไม่ต้องรอ heuristic — Word 16.101+ บน Mac โดนหลอกด้วยหน้าเลือกไฟล์)', () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const saved: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
@ -117,30 +111,13 @@ describe('version open lifecycle', () => {
})
const state = mount()
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
// ยังไม่มีอะไรเกิดก่อนหมดเวลารอ
vi.advanceTimersByTime(2999)
expect(saved).toHaveLength(0)
expect(state.openNotice.value).toBeNull()
// ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล
vi.advanceTimersByTime(1)
expect(saved).toHaveLength(1)
expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb')
expect(state.openNotice.value).toContain('Downloads')
// ยังยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังใช้ได้ (Windows / Word รุ่นก่อน)
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
})
it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const saved: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
saved.push(this)
})
const state = mount()
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word
vi.advanceTimersByTime(3000)
expect(saved).toHaveLength(0)
expect(state.openNotice.value).toBeNull()
})
it('re-click cancels the pending auto-download — only the latest URL is saved once', () => {
it('re-click downloads again with the latest URL', () => {
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
const saved: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
@ -148,10 +125,9 @@ describe('version open lifecycle', () => {
})
const state = mount()
state.openDocxUrl('https://files.example/first.docx')
vi.advanceTimersByTime(2000)
state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า
vi.advanceTimersByTime(3000)
expect(saved).toHaveLength(1)
expect(saved[0].href).toBe('https://files.example/second.docx')
state.openDocxUrl('https://files.example/second.docx') // resetOpen เคลียร์ notice เดิมแล้วดาวน์โหลดใหม่
expect(saved).toHaveLength(2)
expect(saved[1].href).toBe('https://files.example/second.docx')
expect(state.fallbackUrl.value).toBe('https://files.example/second.docx')
})
})