2026-04-21 08:33:37 -03:00
|
|
|
import { afterEach, describe, expect, it, jest } from "@jest/globals";
|
|
|
|
|
import { flushPromises, mount, shallowMount } from "@vue/test-utils";
|
2026-06-29 14:49:49 -03:00
|
|
|
import { nextTick } from "vue";
|
2026-04-21 08:33:37 -03:00
|
|
|
|
|
|
|
|
import axios from "axios";
|
|
|
|
|
|
|
|
|
|
import App from "../../../frontend/src/App.vue";
|
|
|
|
|
import HeroSection from "../../../frontend/src/components/HeroSection.vue";
|
|
|
|
|
import MetricsPanel from "../../../frontend/src/components/MetricsPanel.vue";
|
|
|
|
|
import TopBar from "../../../frontend/src/components/TopBar.vue";
|
|
|
|
|
import { auth } from "../../../frontend/src/auth.js";
|
|
|
|
|
import AboutView from "../../../frontend/src/views/AboutView.vue";
|
|
|
|
|
import AiPlanView from "../../../frontend/src/views/AiPlanView.vue";
|
|
|
|
|
import AppsView from "../../../frontend/src/views/AppsView.vue";
|
|
|
|
|
import MoneroView from "../../../frontend/src/views/MoneroView.vue";
|
|
|
|
|
|
|
|
|
|
jest.mock("axios", () => ({
|
|
|
|
|
__esModule: true,
|
|
|
|
|
default: {
|
|
|
|
|
get: jest.fn(),
|
|
|
|
|
},
|
|
|
|
|
}), { virtual: true });
|
|
|
|
|
|
|
|
|
|
jest.mock("vue-router", () => ({
|
|
|
|
|
RouterLink: {
|
|
|
|
|
name: "RouterLink",
|
|
|
|
|
props: ["to"],
|
2026-06-29 13:23:07 -03:00
|
|
|
template: "<a :href=\"typeof to === 'string' ? to : '#'\" @click=\"$emit('click', $event)\"><slot /></a>",
|
2026-04-21 08:33:37 -03:00
|
|
|
},
|
2026-08-01 03:24:51 -03:00
|
|
|
useRoute: () => ({ meta: { homepageFocus: "sdet" }, params: {}, query: {} }),
|
2026-04-21 08:33:37 -03:00
|
|
|
}), { virtual: true });
|
|
|
|
|
|
|
|
|
|
describe("static shell views and components", () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
jest.restoreAllMocks();
|
|
|
|
|
jest.useRealTimers();
|
2026-06-29 13:23:07 -03:00
|
|
|
document.body.style.overflow = "";
|
2026-04-21 08:33:37 -03:00
|
|
|
auth.enabled = false;
|
2026-06-29 13:23:07 -03:00
|
|
|
auth.ready = false;
|
2026-04-21 08:33:37 -03:00
|
|
|
auth.authenticated = false;
|
|
|
|
|
auth.resetUrl = "";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders the simple static pages", () => {
|
|
|
|
|
const about = shallowMount(AboutView);
|
|
|
|
|
expect(about.text()).toContain("About Me");
|
|
|
|
|
expect(about.text()).toContain("Titan Lab");
|
2026-08-01 03:24:51 -03:00
|
|
|
expect(about.text()).toContain("Senior SDET / DevOps Automation Engineer");
|
2026-06-29 13:23:07 -03:00
|
|
|
expect(about.text()).toContain("Kubernetes");
|
|
|
|
|
expect(about.text()).toContain("IBM Cloud");
|
|
|
|
|
expect(about.text()).toContain("TradeHat");
|
|
|
|
|
expect(about.find("a[href='https://www.tradehat.com/']").exists()).toBe(true);
|
|
|
|
|
expect(about.find("a[href='https://www.unifocus.com/']").exists()).toBe(true);
|
|
|
|
|
expect(about.find("a[href='https://scm.bstein.dev/bstein/titan-iac']").exists()).toBe(true);
|
2026-04-21 08:33:37 -03:00
|
|
|
|
|
|
|
|
const apps = shallowMount(AppsView);
|
|
|
|
|
expect(apps.text()).toContain("Apps");
|
|
|
|
|
expect(apps.text()).toContain("Nextcloud");
|
|
|
|
|
expect(apps.vm.sections.map((section) => section.title)).toContain("Security");
|
2026-06-29 13:23:07 -03:00
|
|
|
const appLinks = apps.vm.sections.flatMap((section) => section.groups.flatMap((group) => group.apps));
|
|
|
|
|
expect(appLinks.find((app) => app.name === "AI Chat")).toMatchObject({
|
|
|
|
|
url: "https://bstein.dev/ai/chat",
|
|
|
|
|
target: "_blank",
|
|
|
|
|
});
|
|
|
|
|
expect(appLinks.find((app) => app.name === "Monero Node")).toMatchObject({
|
|
|
|
|
url: "https://bstein.dev/monero",
|
|
|
|
|
target: "_blank",
|
|
|
|
|
});
|
2026-04-21 08:33:37 -03:00
|
|
|
|
|
|
|
|
const aiPlan = shallowMount(AiPlanView);
|
|
|
|
|
expect(aiPlan.text()).toContain("Roadmap");
|
|
|
|
|
expect(aiPlan.text()).toContain("AI Image");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders hero and metrics panels with status variants", () => {
|
|
|
|
|
const loadingHero = shallowMount(HeroSection, {
|
|
|
|
|
props: {
|
|
|
|
|
title: "Atlas",
|
|
|
|
|
subtitle: "Homelab",
|
|
|
|
|
links: [{ label: "Metrics", href: "https://metrics.example.dev" }],
|
|
|
|
|
loading: true,
|
|
|
|
|
error: "",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(loadingHero.text()).toContain("Loading live data");
|
|
|
|
|
|
|
|
|
|
const errorHero = shallowMount(HeroSection, {
|
|
|
|
|
props: { title: "Atlas", subtitle: "Homelab", links: [], loading: false, error: "offline" },
|
|
|
|
|
});
|
|
|
|
|
expect(errorHero.text()).toContain("offline");
|
|
|
|
|
|
|
|
|
|
const connectedHero = shallowMount(HeroSection, {
|
|
|
|
|
props: { title: "Atlas", subtitle: "Homelab", links: [], loading: false, error: "" },
|
|
|
|
|
});
|
|
|
|
|
expect(connectedHero.text()).toContain("Live data connected");
|
|
|
|
|
|
|
|
|
|
const metrics = shallowMount(MetricsPanel, {
|
|
|
|
|
props: {
|
|
|
|
|
metrics: {
|
|
|
|
|
dashboard: "https://metrics.example.dev/d/atlas",
|
|
|
|
|
description: "Live Atlas metrics",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(metrics.find("iframe").attributes("src")).toBe("https://metrics.example.dev/d/atlas");
|
|
|
|
|
expect(metrics.text()).toContain("Live Atlas metrics");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drives top bar navigation and auth state rendering", async () => {
|
2026-06-29 13:23:07 -03:00
|
|
|
global.fetch = jest.fn(async (resource) => {
|
|
|
|
|
const url = typeof resource === "string" ? resource : resource?.url || "";
|
|
|
|
|
if (url.includes("/api/account/member-state")) {
|
|
|
|
|
return new Response(JSON.stringify({ status: "ready", dashboard_url: "/apps", account_url: "/account" }), {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
|
|
|
|
});
|
2026-04-21 08:33:37 -03:00
|
|
|
auth.enabled = true;
|
2026-06-29 13:23:07 -03:00
|
|
|
auth.ready = true;
|
2026-04-21 08:33:37 -03:00
|
|
|
auth.authenticated = false;
|
|
|
|
|
auth.resetUrl = "https://sso.example.dev/reset";
|
2026-06-29 13:23:07 -03:00
|
|
|
const authModule = await import("../../../frontend/src/auth.js");
|
|
|
|
|
const logout = jest.spyOn(authModule, "logout").mockImplementation(() => {});
|
|
|
|
|
jest.spyOn(authModule, "login").mockImplementation(() => {});
|
2026-04-21 08:33:37 -03:00
|
|
|
|
2026-06-29 14:49:49 -03:00
|
|
|
const target = document.createElement("div");
|
|
|
|
|
document.body.appendChild(target);
|
|
|
|
|
const wrapper = mount(TopBar, { attachTo: target });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
expect(wrapper.text()).toContain("Contact");
|
2026-08-01 03:24:51 -03:00
|
|
|
expect(wrapper.text()).toContain("DevOps");
|
|
|
|
|
expect(wrapper.text()).toContain("Developer");
|
|
|
|
|
expect(wrapper.text()).toContain("SDET");
|
2026-06-29 14:49:49 -03:00
|
|
|
expect(wrapper.text()).toContain("Login");
|
|
|
|
|
expect(wrapper.text()).toContain("Register");
|
|
|
|
|
expect(wrapper.text()).not.toContain("Reset Password");
|
|
|
|
|
expect(wrapper.find("a[href='/#contact']").exists()).toBe(true);
|
2026-08-01 03:24:51 -03:00
|
|
|
expect(wrapper.find("a[href='/devops']").exists()).toBe(true);
|
|
|
|
|
expect(wrapper.find("a[href='/developer']").exists()).toBe(true);
|
|
|
|
|
expect(wrapper.find(".mode-link.active").text()).toBe("SDET");
|
2026-06-29 14:49:49 -03:00
|
|
|
|
|
|
|
|
await wrapper.find("button.menu-toggle").trigger("click");
|
|
|
|
|
const nav = wrapper.find("nav");
|
|
|
|
|
expect(nav.classes()).toContain("open");
|
|
|
|
|
const navItems = () => Array.from(nav.element.querySelectorAll("a[href], button:not([disabled])"));
|
|
|
|
|
navItems()[0].focus();
|
|
|
|
|
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }));
|
|
|
|
|
await nextTick();
|
|
|
|
|
expect(document.activeElement).toBe(navItems().at(-1));
|
|
|
|
|
|
|
|
|
|
navItems().at(-1).focus();
|
|
|
|
|
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
|
|
|
|
|
await nextTick();
|
|
|
|
|
expect(document.activeElement).toBe(navItems()[0]);
|
|
|
|
|
|
|
|
|
|
nav.element.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
|
await nextTick();
|
|
|
|
|
expect(nav.classes()).not.toContain("open");
|
|
|
|
|
expect(document.activeElement).toBe(wrapper.find("button.menu-toggle").element);
|
|
|
|
|
|
|
|
|
|
await wrapper.find("button.menu-toggle").trigger("click");
|
|
|
|
|
expect(nav.classes()).toContain("open");
|
|
|
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
|
|
|
await nextTick();
|
|
|
|
|
expect(nav.classes()).not.toContain("open");
|
|
|
|
|
|
|
|
|
|
await wrapper.find("button.menu-toggle").trigger("click");
|
|
|
|
|
await wrapper.find("a[href='/#contact']").trigger("click");
|
|
|
|
|
expect(nav.classes()).not.toContain("open");
|
|
|
|
|
|
|
|
|
|
auth.authenticated = true;
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(wrapper.text()).toContain("Account");
|
|
|
|
|
expect(wrapper.text()).toContain("Open Services");
|
|
|
|
|
await wrapper.findAll("button.button").at(-1).trigger("click");
|
|
|
|
|
expect(logout).toHaveBeenCalled();
|
|
|
|
|
} finally {
|
|
|
|
|
wrapper.unmount();
|
|
|
|
|
target.remove();
|
|
|
|
|
}
|
2026-04-21 08:33:37 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("loads Monero status and handles API failures", async () => {
|
|
|
|
|
axios.get.mockResolvedValueOnce({
|
|
|
|
|
data: { nettype: "mainnet", status: "OK", height: 123, target_height: 125 },
|
|
|
|
|
});
|
|
|
|
|
const ok = mount(MoneroView);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(ok.text()).toContain("mainnet");
|
|
|
|
|
expect(ok.text()).toContain("123");
|
|
|
|
|
|
|
|
|
|
axios.get.mockRejectedValueOnce(new Error("offline"));
|
|
|
|
|
const failed = mount(MoneroView);
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(failed.text()).toContain("Could not reach monerod");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refreshes the app shell status and handles fetch failures", async () => {
|
|
|
|
|
jest.useFakeTimers();
|
|
|
|
|
jest.spyOn(window, "setTimeout");
|
|
|
|
|
jest.spyOn(window, "clearTimeout");
|
|
|
|
|
global.fetch = jest.fn(async () =>
|
|
|
|
|
new Response(JSON.stringify({ connected: true, atlas: { up: true } }), {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const app = mount(App, {
|
|
|
|
|
global: {
|
|
|
|
|
stubs: {
|
|
|
|
|
TopBar: true,
|
|
|
|
|
"router-view": {
|
|
|
|
|
props: ["labStatus", "loading", "error"],
|
|
|
|
|
template: "<div class=\"route-stub\">{{ loading }} {{ error }} {{ labStatus?.connected }}</div>",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(app.text()).toContain("true");
|
2026-06-29 15:19:10 -03:00
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringMatching(/^\/api\/lab\/status\?ts=\d+$/),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
headers: { Accept: "application/json" },
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-04-21 08:33:37 -03:00
|
|
|
|
|
|
|
|
await app.unmount();
|
|
|
|
|
|
|
|
|
|
global.fetch = jest.fn(async () => {
|
|
|
|
|
const err = new Error("aborted");
|
|
|
|
|
err.name = "AbortError";
|
|
|
|
|
throw err;
|
|
|
|
|
});
|
|
|
|
|
const failed = mount(App, {
|
|
|
|
|
global: {
|
|
|
|
|
stubs: {
|
|
|
|
|
TopBar: true,
|
|
|
|
|
"router-view": {
|
|
|
|
|
props: ["labStatus", "loading", "error"],
|
|
|
|
|
template: "<div class=\"route-stub\">{{ loading }} {{ error }}</div>",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await flushPromises();
|
|
|
|
|
expect(failed.text()).toContain("Live data timed out");
|
|
|
|
|
});
|
|
|
|
|
});
|