From 9a470d3a756eee40e33c1c12874aa7fd10360239 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 10:04:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 19 +++ .../src/components/Layout.desktop.test.ts | 1 + .../desktopNotificationManager.test.ts | 80 ++++++++++ .../src/session/desktopNotificationManager.ts | 12 +- .../src/session/desktopUpdateManager.test.ts | 148 ++++++++++++++++++ frontend/src/store/auth.test.ts | 27 ++++ frontend/src/store/auth.ts | 6 +- frontend/src/views/DesktopServerSettings.vue | 2 +- 8 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 frontend/src/session/desktopNotificationManager.test.ts create mode 100644 frontend/src/session/desktopUpdateManager.test.ts diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 56efd37b..125167fd 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -147,3 +147,22 @@ npm run desktop:build:app - Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。 - 生产下载源的不可变制品上传和线上 `latest.json` 原子替换。 - 真实环境下的自动更新安装、系统通知、单实例和完整人工回归。 + +## 8. 2026-07-02 端到端回归优化记录 + +本轮端到端优化仍保持在线桌面客户端边界,不引入离线、本地业务存储或本地权限裁决。 + +已完成的自动化收口: + +- 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。 +- 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。 +- 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。 +- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项,以及自动更新失败恢复路径。 + +仍需人工或真实环境验证: + +- Keychain/凭据库 30 天在线会话恢复。 +- 原生附件上传、下载、保存和打开。 +- 系统通知授权/拒绝的 OS 级交互。 +- 单实例重复启动聚焦主窗口。 +- 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。 diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index ce63f779..c17f7a7a 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -188,6 +188,7 @@ describe("desktop layout shell", () => { expect(serverSettings).not.toContain("copyConnectionDiagnostic"); expect(serverSettings).toContain("确认切换服务器"); expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文"); + expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })"); expect(serverSettings).not.toContain("服务器连接已确认"); expect(preferences).toContain("connectionDiagnostic"); expect(preferences).not.toContain("复制连接诊断"); diff --git a/frontend/src/session/desktopNotificationManager.test.ts b/frontend/src/session/desktopNotificationManager.test.ts new file mode 100644 index 00000000..dba3d545 --- /dev/null +++ b/frontend/src/session/desktopNotificationManager.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getSubscriptionMock = vi.hoisted(() => vi.fn()); +const claimNotificationsMock = vi.hoisted(() => vi.fn()); +const acknowledgeNotificationsMock = vi.hoisted(() => vi.fn()); +const getTokenMock = vi.hoisted(() => vi.fn()); +const getPermissionMock = vi.hoisted(() => vi.fn()); +const showNotificationMock = vi.hoisted(() => vi.fn()); + +vi.mock("../api/desktopNotifications", () => ({ + getDesktopNotificationSubscription: getSubscriptionMock, + claimDesktopNotifications: claimNotificationsMock, + acknowledgeDesktopNotifications: acknowledgeNotificationsMock, +})); + +vi.mock("../utils/auth", () => ({ + getToken: getTokenMock, +})); + +vi.mock("../runtime", () => ({ + getNotificationPermission: getPermissionMock, + isTauriRuntime: () => true, + showSystemNotification: showNotificationMock, +})); + +describe("desktop notification manager", () => { + beforeEach(() => { + vi.useFakeTimers(); + getTokenMock.mockReturnValue("session-token"); + getPermissionMock.mockResolvedValue("granted"); + getSubscriptionMock.mockResolvedValue({ data: { enabled: true } }); + claimNotificationsMock.mockResolvedValue({ + data: { + claim_token: "claim-token", + items: [ + { id: "notification-1" }, + { id: "notification-2" }, + ], + }, + }); + acknowledgeNotificationsMock.mockResolvedValue({ data: {} }); + showNotificationMock.mockResolvedValue(undefined); + }); + + afterEach(async () => { + const { stopDesktopNotificationManager } = await import("./desktopNotificationManager"); + stopDesktopNotificationManager(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => { + showNotificationMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("notification failed")); + const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); + + initDesktopNotificationManager(); + triggerDesktopNotificationPoll(); + await vi.runOnlyPendingTimersAsync(); + + expect(showNotificationMock).toHaveBeenCalledTimes(2); + expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]); + expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1); + }); + + it("does not claim notifications before operating system permission is granted", async () => { + getPermissionMock.mockResolvedValue("prompt"); + const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); + + initDesktopNotificationManager(); + triggerDesktopNotificationPoll(); + await vi.runOnlyPendingTimersAsync(); + + expect(claimNotificationsMock).not.toHaveBeenCalled(); + expect(acknowledgeNotificationsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/session/desktopNotificationManager.ts b/frontend/src/session/desktopNotificationManager.ts index e23dd83c..18430da8 100644 --- a/frontend/src/session/desktopNotificationManager.ts +++ b/frontend/src/session/desktopNotificationManager.ts @@ -41,13 +41,21 @@ const poll = async () => { } const { data } = await claimDesktopNotifications(); const deliveredIds: string[] = []; + let deliveryFailed = false; for (const item of data.items) { - await showSystemNotification(); - deliveredIds.push(item.id); + try { + await showSystemNotification(); + deliveredIds.push(item.id); + } catch { + deliveryFailed = true; + } } if (data.claim_token && deliveredIds.length) { await acknowledgeDesktopNotifications(data.claim_token, deliveredIds); } + if (deliveryFailed) { + throw new Error("desktop notification delivery failed"); + } failureCount = 0; schedule(POLL_INTERVAL_MS); } catch { diff --git a/frontend/src/session/desktopUpdateManager.test.ts b/frontend/src/session/desktopUpdateManager.test.ts new file mode 100644 index 00000000..05667318 --- /dev/null +++ b/frontend/src/session/desktopUpdateManager.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const checkForDesktopUpdateMock = vi.hoisted(() => vi.fn()); +const installPendingDesktopUpdateMock = vi.hoisted(() => vi.fn()); +const isDesktopUpdaterAvailableMock = vi.hoisted(() => vi.fn()); +const messageErrorMock = vi.hoisted(() => vi.fn()); +const confirmMock = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime", () => ({ + checkForDesktopUpdate: checkForDesktopUpdateMock, + installPendingDesktopUpdate: installPendingDesktopUpdateMock, + isDesktopUpdaterAvailable: isDesktopUpdaterAvailableMock, +})); + +vi.mock("element-plus", () => ({ + ElMessage: { + error: messageErrorMock, + }, + ElMessageBox: { + confirm: confirmMock, + }, +})); + +const createStorage = (): Storage => { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear: vi.fn(() => data.clear()), + getItem: vi.fn((key: string) => data.get(key) ?? null), + key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null), + removeItem: vi.fn((key: string) => data.delete(key)), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + }; +}; + +const update = { + version: "0.1.1", + currentVersion: "0.1.0", + notes: "桌面端稳定化", + date: "2026-07-02", +}; + +describe("desktop update manager", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z")); + vi.resetModules(); + vi.clearAllMocks(); + Object.defineProperty(window, "localStorage", { + value: createStorage(), + configurable: true, + }); + isDesktopUpdaterAvailableMock.mockReturnValue(true); + checkForDesktopUpdateMock.mockResolvedValue(null); + installPendingDesktopUpdateMock.mockResolvedValue(undefined); + confirmMock.mockResolvedValue(undefined); + }); + + afterEach(async () => { + const { stopDesktopUpdateManager } = await import("./desktopUpdateManager"); + stopDesktopUpdateManager(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("records up-to-date checks without prompting", async () => { + const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); + + const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true }); + + expect(status).toBe("up-to-date"); + expect(confirmMock).not.toHaveBeenCalled(); + expect(getDesktopUpdateStatus()).toMatchObject({ + lastStatus: "up-to-date", + pendingUpdate: null, + lastError: "", + }); + }); + + it("suppresses the same postponed version for 24 hours", async () => { + checkForDesktopUpdateMock.mockResolvedValue(update); + confirmMock.mockRejectedValueOnce("cancel"); + const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); + + const postponed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true }); + const postponedStatus = getDesktopUpdateStatus(); + const suppressed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true }); + + expect(postponed).toBe("postponed"); + expect(suppressed).toBe("suppressed"); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(installPendingDesktopUpdateMock).not.toHaveBeenCalled(); + expect(postponedStatus.postponedUntil).toBe("2026-07-03T00:00:00.000Z"); + expect(getDesktopUpdateStatus()).toMatchObject({ + lastStatus: "suppressed", + pendingUpdate: update, + postponedUntil: "2026-07-03T00:00:00.000Z", + }); + }); + + it("keeps a pending update retryable when installation fails", async () => { + checkForDesktopUpdateMock.mockResolvedValue(update); + installPendingDesktopUpdateMock.mockRejectedValueOnce(new Error("install failed")); + const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); + + const status = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true }); + + expect(status).toBe("failed"); + expect(messageErrorMock).toHaveBeenCalledWith("桌面端更新安装失败,请稍后重试或联系管理员。"); + expect(getDesktopUpdateStatus()).toMatchObject({ + installing: false, + lastStatus: "failed", + lastError: "桌面端更新安装失败", + pendingUpdate: update, + }); + }); + + it("does not interrupt timed update checks when checking fails", async () => { + checkForDesktopUpdateMock.mockRejectedValueOnce(new Error("feed unavailable")); + const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); + + const status = await checkDesktopUpdateAndPrompt(); + + expect(status).toBe("failed"); + expect(messageErrorMock).not.toHaveBeenCalled(); + expect(getDesktopUpdateStatus()).toMatchObject({ + lastStatus: "failed", + lastError: "feed unavailable", + }); + }); + + it("reports disabled updater builds explicitly", async () => { + isDesktopUpdaterAvailableMock.mockReturnValue(false); + const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); + + const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true }); + + expect(status).toBe("disabled"); + expect(checkForDesktopUpdateMock).not.toHaveBeenCalled(); + expect(getDesktopUpdateStatus()).toMatchObject({ + available: false, + lastStatus: "disabled", + pendingUpdate: null, + }); + }); +}); diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index 90a0391b..c6a0ab4f 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -99,6 +99,33 @@ describe("auth store logout", () => { expect(session.timeoutWarningVisible).toBe(false); }); + it("can logout without remembering the current study during desktop server switch", async () => { + const { useAuthStore } = await import("./auth"); + const { useStudyStore } = await import("./study"); + const auth = useAuthStore(); + const study = useStudyStore(); + + study.setCurrentStudy({ + id: "study-old-server", + name: "旧服务器项目", + status: "ACTIVE", + is_locked: false, + } as any); + auth.user = { + id: "user-1", + email: "admin@test.com", + full_name: "Admin", + clinical_department: "Admin", + status: "ACTIVE", + is_admin: true, + }; + + await auth.logout({ rememberCurrentStudy: false }); + + expect(window.localStorage.getItem("ctms_last_study_by_user")).toBeNull(); + expect(study.currentStudy).toBeNull(); + }); + it("uses encrypted login by default outside a secure browser context", async () => { vi.stubGlobal("isSecureContext", false); const { useAuthStore } = await import("./auth"); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index a33308b8..d682ec86 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -62,11 +62,13 @@ export const useAuthStore = defineStore("auth", () => { return data; }; - const logout = async () => { + const logout = async (options: { rememberCurrentStudy?: boolean } = {}) => { const studyStore = useStudyStore(); const sessionStore = useSessionStore(); const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || ""; - studyStore.rememberCurrentStudyForUser(userKey); + if (options.rememberCurrentStudy !== false) { + studyStore.rememberCurrentStudyForUser(userKey); + } token.value = null; user.value = null; forceLogin.value = false; diff --git a/frontend/src/views/DesktopServerSettings.vue b/frontend/src/views/DesktopServerSettings.vue index ae1dfce0..44e48110 100644 --- a/frontend/src/views/DesktopServerSettings.vue +++ b/frontend/src/views/DesktopServerSettings.vue @@ -144,7 +144,7 @@ const checkHealth = async (baseUrl: string) => { }; const clearSessionForServerChange = async () => { - await auth.logout(); + await auth.logout({ rememberCurrentStudy: false }); studyStore.clearCurrentStudy(); };