From 0d03e1656a514469f17c3ba30710709d4ea8450f Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 10:13:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E6=96=87=E4=BB=B6=E6=B5=81=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 4 +- frontend/src/api/attachments.test.ts | 56 ++++++++++ frontend/src/utils/fileTaskFeedback.test.ts | 100 ++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/attachments.test.ts create mode 100644 frontend/src/utils/fileTaskFeedback.test.ts diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 125167fd..d7368df4 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -157,7 +157,9 @@ npm run desktop:build:app - 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。 - 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。 - 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。 -- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项,以及自动更新失败恢复路径。 +- 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。 +- 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。 +- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、自动更新失败恢复路径,以及附件文件流契约。 仍需人工或真实环境验证: diff --git a/frontend/src/api/attachments.test.ts b/frontend/src/api/attachments.test.ts new file mode 100644 index 00000000..01075774 --- /dev/null +++ b/frontend/src/api/attachments.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiDelete = vi.fn(); +const apiGet = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("./axios", () => ({ + apiDelete, + apiGet, + apiPost, +})); + +describe("attachments api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("downloads attachment blobs without putting credentials in the URL", async () => { + const { downloadAttachment } = await import("./attachments"); + + downloadAttachment("attachment-1"); + + expect(apiGet).toHaveBeenCalledWith("/api/v1/attachments/attachment-1/download", { + responseType: "blob", + }); + expect(apiGet.mock.calls[0][0]).not.toContain("token"); + expect(apiGet.mock.calls[0][0]).not.toContain("access_token"); + }); + + it("uploads attachments as multipart form data", async () => { + const { uploadAttachment } = await import("./attachments"); + const file = new File(["content"], "report.pdf", { type: "application/pdf" }); + const onUploadProgress = vi.fn(); + + uploadAttachment("study-1", "startup_initiation", "entity-1", file, { onUploadProgress }); + + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/startup_initiation/entity-1/attachments/", + expect.any(FormData), + { + headers: { "Content-Type": "multipart/form-data" }, + onUploadProgress, + }, + ); + const formData = apiPost.mock.calls[0][1] as FormData; + expect(formData.get("file")).toBe(file); + }); + + it("deletes attachments through the scoped attachment endpoint", async () => { + const { deleteAttachment } = await import("./attachments"); + + deleteAttachment("attachment-1"); + + expect(apiDelete).toHaveBeenCalledWith("/api/v1/attachments/attachment-1"); + }); +}); diff --git a/frontend/src/utils/fileTaskFeedback.test.ts b/frontend/src/utils/fileTaskFeedback.test.ts new file mode 100644 index 00000000..299e9657 --- /dev/null +++ b/frontend/src/utils/fileTaskFeedback.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const capabilitiesMock = vi.hoisted(() => vi.fn()); +const openFileMock = vi.hoisted(() => vi.fn()); +const pickFilesMock = vi.hoisted(() => vi.fn()); +const saveFileMock = vi.hoisted(() => vi.fn()); +const messageInfoMock = vi.hoisted(() => vi.fn()); +const messageSuccessMock = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime", () => ({ + clientRuntime: { + capabilities: capabilitiesMock, + }, + openFile: openFileMock, + pickFiles: pickFilesMock, + saveFile: saveFileMock, +})); + +vi.mock("element-plus", () => ({ + ElMessage: { + info: messageInfoMock, + success: messageSuccessMock, + }, +})); + +const output = { + suggestedName: "report.pdf", + mimeType: "application/pdf", + data: new Blob(["content"], { type: "application/pdf" }), +}; + +describe("file task feedback", () => { + beforeEach(() => { + vi.clearAllMocks(); + capabilitiesMock.mockReturnValue({ nativeFiles: false }); + pickFilesMock.mockResolvedValue([]); + saveFileMock.mockResolvedValue("saved"); + openFileMock.mockResolvedValue(undefined); + }); + + it("reports native file picker selections", async () => { + const files = [ + new File(["a"], "a.txt", { type: "text/plain" }), + new File(["b"], "b.txt", { type: "text/plain" }), + ]; + pickFilesMock.mockResolvedValue(files); + const { pickFilesWithFeedback } = await import("./fileTaskFeedback"); + + const selected = await pickFilesWithFeedback({ multiple: true, title: "附件" }); + + expect(selected).toBe(files); + expect(pickFilesMock).toHaveBeenCalledWith({ multiple: true, title: "附件" }); + expect(messageSuccessMock).toHaveBeenCalledWith("已选择 2 个文件"); + }); + + it("does not show a selection toast when the picker is cancelled", async () => { + const { pickFilesWithFeedback } = await import("./fileTaskFeedback"); + + await expect(pickFilesWithFeedback({ multiple: true })).resolves.toEqual([]); + + expect(messageSuccessMock).not.toHaveBeenCalled(); + }); + + it("uses download feedback for web save flows", async () => { + const { saveFileWithFeedback } = await import("./fileTaskFeedback"); + + await expect(saveFileWithFeedback(output)).resolves.toBe("saved"); + + expect(saveFileMock).toHaveBeenCalledWith(output); + expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始"); + }); + + it("uses saved feedback for native save flows", async () => { + capabilitiesMock.mockReturnValue({ nativeFiles: true }); + const { saveFileWithFeedback } = await import("./fileTaskFeedback"); + + await expect(saveFileWithFeedback(output)).resolves.toBe("saved"); + + expect(messageSuccessMock).toHaveBeenCalledWith("文件已保存"); + }); + + it("reports cancelled save flows without treating them as downloads", async () => { + saveFileMock.mockResolvedValue("cancelled"); + const { saveFileWithFeedback } = await import("./fileTaskFeedback"); + + await expect(saveFileWithFeedback(output)).resolves.toBe("cancelled"); + + expect(messageInfoMock).toHaveBeenCalledWith("已取消保存文件"); + expect(messageSuccessMock).not.toHaveBeenCalled(); + }); + + it("reports external open completion", async () => { + const { openFileWithFeedback } = await import("./fileTaskFeedback"); + + await openFileWithFeedback(output); + + expect(openFileMock).toHaveBeenCalledWith(output); + expect(messageSuccessMock).toHaveBeenCalledWith("文件已打开"); + }); +});