PMFlow/frontend/src/features/account-settings/accountSettings.test.ts

251 lines
10 KiB
TypeScript

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");
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.showInAppReminder).toBe(false);
expect(iteration.desktopPreference).toBe(false);
expect(iteration.emailDigestPreference).toBe(false);
expect(iteration.mutedDetail).toContain("证据仍必须");
});
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");
});
});