From 36753025960cc8e5180f1a7cf6fa5d7112309152 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 08:52:56 -0300 Subject: [PATCH] test(bstein-home): cover account frontend --- frontend/src/account/useAccountDashboard.js | 6 +- .../frontend/unit/account-dashboard.spec.js | 359 ++++++++++++++++++ 2 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 testing/frontend/unit/account-dashboard.spec.js diff --git a/frontend/src/account/useAccountDashboard.js b/frontend/src/account/useAccountDashboard.js index ed19c32..a5ca904 100644 --- a/frontend/src/account/useAccountDashboard.js +++ b/frontend/src/account/useAccountDashboard.js @@ -302,16 +302,18 @@ export function useAccountDashboard() { const syncEnabled = Boolean(data.sync_enabled); const syncOk = Boolean(data.sync_ok); const syncError = data.sync_error || ""; + let syncWarning = ""; if (!syncEnabled) { mailu.status = "updated"; - mailu.error = "Mail sync is not configured; password may not take effect until an admin sync runs."; + syncWarning = "Mail sync is not configured; password may not take effect until an admin sync runs."; } else if (!syncOk) { mailu.status = "sync pending"; - mailu.error = syncError || "Mail sync did not confirm success yet. Try again in a moment."; + syncWarning = syncError || "Mail sync did not confirm success yet. Try again in a moment."; } else { mailu.status = "updated"; } await refreshOverview(); + if (syncWarning) mailu.error = syncWarning; } catch (err) { mailu.error = formatActionError(err, "Rotation failed"); } finally { diff --git a/testing/frontend/unit/account-dashboard.spec.js b/testing/frontend/unit/account-dashboard.spec.js new file mode 100644 index 0000000..40650d0 --- /dev/null +++ b/testing/frontend/unit/account-dashboard.spec.js @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { flushPromises, mount } from "@vue/test-utils"; +import { defineComponent, nextTick } from "vue"; + +import { auth } from "../../../frontend/src/auth.js"; +import { useAccountDashboard } from "../../../frontend/src/account/useAccountDashboard.js"; +import AccountView from "../../../frontend/src/views/AccountView.vue"; + +function jsonResponse(body, status = 200, statusText = "OK") { + return new Response(JSON.stringify(body), { + status, + statusText, + headers: { "content-type": "application/json" }, + }); +} + +function overview(overrides = {}) { + return { + mailu: { status: "ready", username: "ADA@BSTEIN.DEV", app_password: "mail-pass" }, + nextcloud_mail: { status: "synced", primary_email: "ADA@BSTEIN.DEV", account_count: 1, synced_at: "now" }, + wger: { status: "ready", username: "ADA@BSTEIN.DEV", password: "wger-pass", password_updated_at: "today" }, + firefly: { status: "ready", username: "ADA@BSTEIN.DEV", password: "firefly-pass", password_updated_at: "today" }, + vaultwarden: { status: "already_present", username: "ADA@BSTEIN.DEV", synced_at: "now" }, + jellyfin: { status: "ready", username: "ada", sync_status: "ok", sync_detail: "LDAP synced" }, + onboarding_url: "/onboarding?code=ada", + ...overrides, + }; +} + +function installFetch(handler) { + global.fetch = jest.fn(async (url, options = {}) => handler(String(url), options)); +} + +function authenticate() { + auth.ready = true; + auth.enabled = true; + auth.authenticated = true; + auth.username = "ada"; + auth.email = "Ada@Example.Dev"; + auth.token = "token"; + auth.accountPasswordUrl = "https://sso.example.dev/account/password"; +} + +function mountDashboard() { + let dashboard; + const wrapper = mount(defineComponent({ + setup() { + dashboard = useAccountDashboard(); + return {}; + }, + template: "
", + })); + return { dashboard, wrapper }; +} + +describe("account dashboard", () => { + beforeEach(() => { + jest.useFakeTimers(); + authenticate(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: jest.fn(async () => {}) }, + }); + installFetch((url) => { + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) { + return jsonResponse({ + requests: [{ + username: "newuser", + first_name: "New", + last_name: "User", + email: "new@example.dev", + request_code: "newuser~abc", + note: "please", + }], + }); + } + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: ["media", "budget"] }); + return jsonResponse({}); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + Object.assign(auth, { + ready: false, + enabled: false, + authenticated: false, + username: "", + email: "", + token: "", + accountPasswordUrl: "", + }); + Reflect.deleteProperty(global, "fetch"); + }); + + it("renders login-required state when unauthenticated", async () => { + auth.ready = true; + auth.authenticated = false; + const { dashboard, wrapper } = mountDashboard(); + await nextTick(); + + expect(dashboard.mailu.status).toBe("login required"); + expect(dashboard.vaultwardenDisplayStatus.value).toBe("login required"); + expect(dashboard.admin.enabled).toBe(false); + wrapper.unmount(); + + const view = mount(AccountView); + await nextTick(); + expect(view.text()).toContain("Login required"); + view.unmount(); + }); + + it("reacts when auth readiness changes after mount", async () => { + auth.ready = false; + auth.authenticated = false; + const { dashboard, wrapper } = mountDashboard(); + await nextTick(); + + auth.ready = true; + await nextTick(); + expect(dashboard.mailu.status).toBe("login required"); + expect(dashboard.admin.requests).toEqual([]); + + auth.authenticated = true; + auth.username = "ada"; + auth.email = "ada@example.dev"; + auth.token = "token"; + await flushPromises(); + expect(dashboard.mailu.username).toBe("ada@bstein.dev"); + expect(dashboard.admin.enabled).toBe(true); + wrapper.unmount(); + }); + + it("loads overview, admin requests, and renders the authenticated view", async () => { + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + expect(dashboard.mailu.username).toBe("ada@bstein.dev"); + expect(dashboard.mailu.currentPassword).toBe("mail-pass"); + expect(dashboard.wger.password).toBe("wger-pass"); + expect(dashboard.firefly.password).toBe("firefly-pass"); + expect(dashboard.vaultwardenReady.value).toBe(true); + expect(dashboard.vaultwardenOrder.value).toBe(3); + expect(dashboard.jellyfin.syncStatus).toBe("ok"); + expect(dashboard.onboardingUrl.value).toBe("/onboarding?code=ada"); + expect(dashboard.admin.enabled).toBe(true); + expect(dashboard.admin.flags).toEqual(["media", "budget"]); + expect(dashboard.hasFlag("newuser", "media")).toBe(false); + expect(dashboard.formatName(dashboard.admin.requests[0])).toBe("New User"); + expect(dashboard.formatName({ first_name: " ", last_name: "" })).toBe("unknown"); + expect(dashboard.formatName()).toBe("unknown"); + + dashboard.toggleFlag("newuser", "media", { target: { checked: true } }); + dashboard.toggleFlag("newuser", "budget", { target: { checked: true } }); + dashboard.toggleFlag("newuser", "media", { target: { checked: false } }); + expect(dashboard.admin.selectedFlags.newuser).toEqual(["budget"]); + wrapper.unmount(); + + const view = mount(AccountView); + await flushPromises(); + expect(view.text()).toContain("Firefly III"); + expect(view.text()).toContain("Admin Approvals"); + expect(view.text()).toContain("newuser"); + expect(view.find("a[href='https://sso.example.dev/account/password']").exists()).toBe(true); + view.unmount(); + }); + + it("rotates service credentials and syncs account services", async () => { + installFetch((url) => { + if (url.includes("/mailu/rotate")) return jsonResponse({ password: "new-mail", sync_enabled: true, sync_ok: true }); + if (url.includes("/wger/reset")) return jsonResponse({ password: "new-wger" }); + if (url.includes("/firefly/reset")) return jsonResponse({ password: "new-firefly" }); + if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ ok: true }); + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] }); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + await dashboard.rotateMailu(); + expect(dashboard.mailu.currentPassword).toBe("mail-pass"); + expect(dashboard.mailu.rotating).toBe(false); + + await dashboard.resetWger(); + expect(dashboard.wger.password).toBe("wger-pass"); + expect(dashboard.wger.resetting).toBe(false); + + await dashboard.resetFirefly(); + expect(dashboard.firefly.password).toBe("firefly-pass"); + expect(dashboard.firefly.resetting).toBe(false); + + await dashboard.syncNextcloudMail(); + expect(dashboard.nextcloudMail.syncing).toBe(false); + wrapper.unmount(); + }); + + it("handles service action warnings and failures", async () => { + const rotateResponses = [ + { password: "mail-a", sync_enabled: false }, + { password: "mail-b", sync_enabled: true, sync_ok: false, sync_error: "sync slow" }, + { error: "ariadne unavailable" }, + ]; + installFetch((url) => { + if (url.includes("/mailu/rotate")) { + const body = rotateResponses.shift(); + return body.error ? jsonResponse(body, 503, "Service Unavailable") : jsonResponse(body); + } + if (url.includes("/wger/reset")) return jsonResponse({ error: "status 502" }, 502, "Bad Gateway"); + if (url.includes("/firefly/reset")) return jsonResponse({}, 500, "Server Error"); + if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ error: "status 503" }, 503, "Service Unavailable"); + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] }); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + await dashboard.rotateMailu(); + expect(dashboard.mailu.error).toContain("Mail sync is not configured"); + await dashboard.rotateMailu(); + expect(dashboard.mailu.error).toBe("sync slow"); + await dashboard.rotateMailu(); + expect(dashboard.mailu.error).toBe("Ariadne is busy. Please try again in a moment."); + + await dashboard.resetWger(); + expect(dashboard.wger.error).toBe("Ariadne is busy. Please try again in a moment."); + + await dashboard.resetFirefly(); + expect(dashboard.firefly.error).toBe("status 500"); + + await dashboard.syncNextcloudMail(); + expect(dashboard.nextcloudMail.error).toContain("Refresh in a moment"); + + installFetch((url) => { + if (url.includes("/nextcloud/mail/sync")) return jsonResponse({ error: "plain sync failed" }, 500, "Server Error"); + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] }); + return jsonResponse({}); + }); + await dashboard.syncNextcloudMail(); + expect(dashboard.nextcloudMail.error).toBe("plain sync failed"); + wrapper.unmount(); + }); + + it("approves, denies, and copies admin/account values", async () => { + const seen = []; + installFetch((url, options) => { + seen.push({ url, body: options.body }); + if (url.includes("/approve") || url.includes("/deny")) return jsonResponse({ ok: true }); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: ["media"] }); + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + dashboard.admin.selectedFlags.ada = ["media"]; + dashboard.admin.notes.ada = " approve me "; + await dashboard.approve("ada"); + expect(JSON.parse(seen.find((item) => item.url.includes("/approve")).body)).toEqual({ flags: ["media"], note: "approve me" }); + + dashboard.admin.notes.ada = " no "; + await dashboard.deny("ada"); + expect(JSON.parse(seen.find((item) => item.url.includes("/deny")).body)).toEqual({ note: "no" }); + + await dashboard.copy("mail", "secret"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("secret"); + expect(dashboard.copied.mail).toBe(true); + jest.advanceTimersByTime(1500); + expect(dashboard.copied.mail).toBe(false); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: jest.fn(async () => { throw new Error("blocked"); }) }, + }); + Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => true) }); + await dashboard.copy("fallback", "secret2"); + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(dashboard.copied.fallback).toBe(true); + jest.advanceTimersByTime(1500); + expect(dashboard.copied.fallback).toBe(false); + + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + await dashboard.copy("direct-fallback", "secret3"); + expect(dashboard.copied["direct-fallback"]).toBe(true); + + Object.defineProperty(document, "execCommand", { configurable: true, value: jest.fn(() => { throw new Error("no copy"); }) }); + await dashboard.copy("ignored", "secret4"); + expect(dashboard.copied.ignored).toBeUndefined(); + + await dashboard.copy("empty", ""); + expect(dashboard.copied.empty).toBeUndefined(); + wrapper.unmount(); + }); + + it("surfaces overview and admin loading failures", async () => { + installFetch((url) => { + if (url.includes("/api/account/overview")) return jsonResponse({ error: "overview down" }, 500, "Server Error"); + if (url.includes("/api/admin/access/requests")) return jsonResponse({}, 403, "Forbidden"); + if (url.includes("/api/admin/access/flags")) return jsonResponse({}, 500, "Server Error"); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + expect(dashboard.mailu.status).toBe("unavailable"); + expect(dashboard.mailu.error).toContain("overview down"); + expect(dashboard.admin.enabled).toBe(false); + expect(dashboard.admin.flags).toEqual([]); + expect(dashboard.admin.error).toBe("status 500"); + wrapper.unmount(); + }); + + it("handles admin request errors and forbidden flag lists", async () => { + installFetch((url) => { + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({}, 500, "Server Error"); + if (url.includes("/api/admin/access/flags")) return jsonResponse({}, 403, "Forbidden"); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + expect(dashboard.admin.enabled).toBe(false); + expect(dashboard.admin.requests).toEqual([]); + expect(dashboard.admin.flags).toEqual([]); + expect(dashboard.admin.error).toBe("status 500"); + wrapper.unmount(); + }); + + it("reports admin action failures without leaving acting locks stuck", async () => { + installFetch((url) => { + if (url.includes("/approve")) return jsonResponse({ error: "approve failed" }, 500, "Server Error"); + if (url.includes("/deny")) return jsonResponse({}, 500, "Server Error"); + if (url.includes("/api/account/overview")) return jsonResponse(overview()); + if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] }); + if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] }); + return jsonResponse({}); + }); + const { dashboard, wrapper } = mountDashboard(); + await flushPromises(); + + await dashboard.approve("ada"); + expect(dashboard.admin.error).toBe("approve failed"); + expect(dashboard.admin.acting.ada).toBe(false); + + await dashboard.deny("ada"); + expect(dashboard.admin.error).toBe("status 500"); + expect(dashboard.admin.acting.ada).toBe(false); + wrapper.unmount(); + }); +});