import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { createDefaultAccountSettings, getAccountNotificationConsumption, getAccountSettings, getPostReleaseNotificationPreference, getReleaseGateNotificationPreference, shouldShowAccountNotification, updateAccountSettings, } from "@/api/accountSettings"; describe("account settings api", () => { const storageSetItem = Storage.prototype.setItem; const originalFetch = globalThis.fetch; beforeEach(() => { window.localStorage.clear(); Storage.prototype.setItem = storageSetItem; globalThis.fetch = originalFetch; }); afterEach(() => { Storage.prototype.setItem = storageSetItem; globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); it("creates defaults from the current account seed", async () => { const settings = await getAccountSettings({ displayName: "陈雨", roleTitle: "独立开发者", email: "yu@example.com", }); expect(settings.profile.displayName).toBe("陈雨"); expect(settings.profile.roleTitle).toBe("独立开发者"); expect(settings.profile.email).toBe("yu@example.com"); expect(settings.saveScope).toBe("local"); expect(settings.aiPreferences.answerDepth).toBe("balanced"); expect(settings.notifications.pendingConfirmation.enabled).toBe(true); expect(settings.notifications.executionWriteback.enabled).toBe(true); expect(settings.notifications.releaseGate.enabled).toBe(true); expect(settings.notifications.failureAlert.enabled).toBe(true); }); it("persists updated preferences through the api layer", async () => { const defaults = createDefaultAccountSettings({ displayName: "陈雨" }); const saved = await updateAccountSettings({ ...defaults, profile: { ...defaults.profile, defaultLanguage: "en-US" }, aiPreferences: { answerDepth: "deep", evidenceDisplay: "full" }, notifications: { ...defaults.notifications, failureAlert: { enabled: false, desktop: false, emailDigest: false }, }, }); const restored = await getAccountSettings({ displayName: "陈雨" }); expect(saved.updatedAt).toBeTruthy(); expect(saved.saveScope).toBe("local"); expect(restored.profile.defaultLanguage).toBe("en-US"); expect(restored.aiPreferences.answerDepth).toBe("deep"); expect(restored.aiPreferences.evidenceDisplay).toBe("full"); expect(restored.notifications.failureAlert.enabled).toBe(false); }); it("exposes notification preferences as product surface consumption rules", () => { const defaults = createDefaultAccountSettings(); const settings = { ...defaults, notifications: { ...defaults.notifications, executionWriteback: { enabled: false, desktop: false, emailDigest: false }, releaseGate: { enabled: true, desktop: false, emailDigest: true }, }, }; const consumption = getAccountNotificationConsumption(settings); const execution = consumption.find((item) => item.key === "executionWriteback"); const release = consumption.find((item) => item.key === "releaseGate"); expect(shouldShowAccountNotification(settings, "pendingConfirmation")).toBe(true); expect(shouldShowAccountNotification(settings, "executionWriteback")).toBe(false); expect(execution?.productArea).toBe("Execution Bridge"); expect(execution?.showInAppReminder).toBe(false); expect(execution?.statusLabel).toBe("页面内提醒静默"); expect(release?.productArea).toBe("Release / Growth"); expect(release?.showInAppReminder).toBe(true); expect(release?.emailDigestPreference).toBe(true); }); it("maps release gate and release validation events to the releaseGate preference", () => { const defaults = createDefaultAccountSettings(); const settings = { ...defaults, notifications: { ...defaults.notifications, releaseGate: { enabled: true, desktop: false, emailDigest: true }, }, }; const blockedGate = getReleaseGateNotificationPreference(settings, "release_gate_blocked"); const validation = getReleaseGateNotificationPreference(settings, "release_validation_needs_confirmation"); expect(blockedGate.notificationKey).toBe("releaseGate"); expect(blockedGate.surface).toBe("release_growth"); expect(blockedGate.inAppEnabled).toBe(true); expect(blockedGate.desktopPreference).toBe(false); expect(blockedGate.emailDigestPreference).toBe(true); expect(blockedGate.reason).toContain("发布门禁"); expect(validation.notificationKey).toBe("releaseGate"); expect(validation.inAppEnabled).toBe(true); expect(validation.reason).toContain("发布验证"); }); it("maps release failed events to failureAlert without enabling disabled channels", () => { const defaults = createDefaultAccountSettings(); const settings = { ...defaults, notifications: { ...defaults.notifications, failureAlert: { enabled: false, desktop: true, emailDigest: true }, }, }; const failedEvent = getReleaseGateNotificationPreference(settings, "failed_event"); expect(failedEvent.notificationKey).toBe("failureAlert"); expect(failedEvent.surface).toBe("system_failure"); expect(failedEvent.inAppEnabled).toBe(false); expect(failedEvent.desktopPreference).toBe(false); expect(failedEvent.emailDigestPreference).toBe(false); expect(failedEvent.reason).toContain("failed event"); }); it("maps post-release feedback, revenue, compliance, and iteration events to local reminder preferences", () => { const defaults = createDefaultAccountSettings(); const settings = { ...defaults, notifications: { ...defaults.notifications, releaseGate: { enabled: true, desktop: false, emailDigest: true }, pendingConfirmation: { enabled: false, desktop: true, emailDigest: true }, }, }; const feedback = getPostReleaseNotificationPreference(settings, "feedback_received"); const revenue = getPostReleaseNotificationPreference(settings, "revenue_signal"); const compliance = getPostReleaseNotificationPreference(settings, "compliance_blocked"); const iteration = getPostReleaseNotificationPreference(settings, "iteration_recommendation"); const nextReview = getPostReleaseNotificationPreference(settings, "next_change_review"); expect(feedback.notificationKey).toBe("releaseGate"); expect(feedback.surface).toBe("release_growth"); expect(feedback.spaceReadable).toBe(true); expect(feedback.showInAppReminder).toBe(true); expect(feedback.desktopPreference).toBe(false); expect(feedback.emailDigestPreference).toBe(true); expect(feedback.reminderPolicy).toBe("page_reminder_only"); expect(feedback.evidencePolicy).toBe("preserve_business_evidence"); expect(feedback.mutedDetail).toContain("不代表系统级推送已接入"); expect(revenue.notificationKey).toBe("releaseGate"); expect(revenue.reason).toContain("收入"); expect(compliance.notificationKey).toBe("releaseGate"); expect(compliance.reason).toContain("合规"); expect(iteration.notificationKey).toBe("pendingConfirmation"); expect(iteration.surface).toBe("change_review"); expect(iteration.showInAppReminder).toBe(false); expect(iteration.desktopPreference).toBe(false); expect(iteration.emailDigestPreference).toBe(false); expect(iteration.mutedDetail).toContain("证据仍必须"); expect(nextReview.notificationKey).toBe("pendingConfirmation"); expect(nextReview.surface).toBe("change_review"); expect(nextReview.showInAppReminder).toBe(false); expect(nextReview.reason).toContain("ChangeSet"); expect(nextReview.mutedDetail).toContain("ChangeSet"); }); it("falls back missing notification fields to safe defaults", async () => { window.localStorage.setItem( "pmflow:account-settings:v1", JSON.stringify({ notifications: { failureAlert: { enabled: false }, }, }) ); const restored = await getAccountSettings(); const consumption = getAccountNotificationConsumption(restored); expect(restored.notifications.pendingConfirmation.desktop).toBe(true); expect(restored.notifications.failureAlert.enabled).toBe(false); expect(restored.notifications.failureAlert.desktop).toBe(true); expect(shouldShowAccountNotification(restored, "failureAlert")).toBe(false); expect(consumption.find((item) => item.key === "failureAlert")?.detail).toContain("仍保留"); }); it("fails explicitly when local persistence is unavailable", async () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage denied"); }); await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied"); }); it("uses account sync when an authenticated backend contract is available", async () => { window.localStorage.setItem("pmflow_token", "token"); const accountSaved = { ...createDefaultAccountSettings({ displayName: "陈雨", email: "yu@example.com" }), saveScope: "account" as const, aiPreferences: { answerDepth: "deep" as const, evidenceDisplay: "full" as const }, updatedAt: "2026-04-26T10:00:00", }; const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: 0, message: "success", data: accountSaved }), { status: 200, headers: { "Content-Type": "application/json" }, }) ); globalThis.fetch = fetchMock; const saved = await updateAccountSettings(accountSaved); expect(saved.saveScope).toBe("account"); expect(saved.aiPreferences.answerDepth).toBe("deep"); expect(fetchMock).toHaveBeenCalledWith( "/api/account-settings/me", expect.objectContaining({ method: "PUT", }) ); }); it("falls back to honest local persistence when backend sync fails", async () => { window.localStorage.setItem("pmflow_token", "token"); vi.spyOn(console, "error").mockImplementation(() => undefined); globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: 500, message: "backend unavailable", data: null }), { status: 500, headers: { "Content-Type": "application/json" }, }) ); const defaults = createDefaultAccountSettings({ displayName: "陈雨" }); const saved = await updateAccountSettings({ ...defaults, aiPreferences: { answerDepth: "deep", evidenceDisplay: "inline" }, }); expect(saved.saveScope).toBe("local"); expect(saved.aiPreferences.answerDepth).toBe("deep"); const restored = await getAccountSettings({ displayName: "陈雨" }); expect(restored.saveScope).toBe("local"); expect(restored.aiPreferences.answerDepth).toBe("deep"); }); });