test(bstein-home): cover account frontend

This commit is contained in:
codex 2026-04-21 08:52:56 -03:00
parent 1348aa85a8
commit 3675302596
2 changed files with 363 additions and 2 deletions

View File

@ -302,16 +302,18 @@ export function useAccountDashboard() {
const syncEnabled = Boolean(data.sync_enabled); const syncEnabled = Boolean(data.sync_enabled);
const syncOk = Boolean(data.sync_ok); const syncOk = Boolean(data.sync_ok);
const syncError = data.sync_error || ""; const syncError = data.sync_error || "";
let syncWarning = "";
if (!syncEnabled) { if (!syncEnabled) {
mailu.status = "updated"; 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) { } else if (!syncOk) {
mailu.status = "sync pending"; 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 { } else {
mailu.status = "updated"; mailu.status = "updated";
} }
await refreshOverview(); await refreshOverview();
if (syncWarning) mailu.error = syncWarning;
} catch (err) { } catch (err) {
mailu.error = formatActionError(err, "Rotation failed"); mailu.error = formatActionError(err, "Rotation failed");
} finally { } finally {

View File

@ -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: "<div />",
}));
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();
});
});