Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ActionsTab } from "../ActionsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "Disk usage",
|
||||
task_type: "shell",
|
||||
content: "df -h",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ActionsTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActionsTab", () => {
|
||||
it("renders the saved-actions rail and task detail", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Saved actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disk usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Add action button", () => {
|
||||
renderTab();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AlertsTab } from "../AlertsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "am-1",
|
||||
service_type: "alertmanager",
|
||||
name: "Main Alertmanager",
|
||||
config: { base_url: "https://am.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useAlertmanagerAlerts: () => ({
|
||||
data: {
|
||||
total: 2,
|
||||
by_severity: { critical: 1, warning: 1 },
|
||||
alerts: [
|
||||
{
|
||||
name: "DiskFull",
|
||||
severity: "critical",
|
||||
category: "disk",
|
||||
job_name: "node",
|
||||
summary: "Disk is almost full",
|
||||
description: "Disk usage above 90%",
|
||||
active_since: "2026-06-26T10:00:00Z",
|
||||
state: "firing",
|
||||
labels: { instance: "node1" },
|
||||
},
|
||||
{
|
||||
name: "HighCpu",
|
||||
severity: "warning",
|
||||
category: "cpu",
|
||||
job_name: "node",
|
||||
summary: "High CPU usage",
|
||||
description: "",
|
||||
active_since: "2026-06-26T09:00:00Z",
|
||||
state: "firing",
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useAlertmanagerStatus: () => ({
|
||||
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AlertsTab", () => {
|
||||
it("renders the alert count and alert names", () => {
|
||||
render(<AlertsTab instance={instance} />);
|
||||
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText("DiskFull")).toBeInTheDocument();
|
||||
expect(screen.getByText("HighCpu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders severity badges", () => {
|
||||
render(<AlertsTab instance={instance} />);
|
||||
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||
expect(screen.getByText("warning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FilesTab } from "../FilesTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: {
|
||||
count: 2,
|
||||
entries: [
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
|
||||
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
|
||||
initial(),
|
||||
vi.fn(),
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<FilesTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FilesTab", () => {
|
||||
it("renders the directory listing with instance-scoped hooks", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the path bar and browser section", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Browser")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JobsTab } from "../JobsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "bkp-1",
|
||||
service_type: "backups",
|
||||
name: "Main Backups",
|
||||
config: { ingestion_label: "default" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useBackups", () => ({
|
||||
useBackupJobs: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "job-1",
|
||||
name: "nightly",
|
||||
source: "/data",
|
||||
target: "s3://bucket",
|
||||
schedule_interval_seconds: 86400,
|
||||
created_at: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useBackupRuns: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
useBackupAlerts: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(<JobsTab instance={instance} />);
|
||||
}
|
||||
|
||||
describe("JobsTab", () => {
|
||||
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
|
||||
renderTab();
|
||||
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the backup job name in the Jobs tab", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LinksTab } from "../LinksTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "graf-1",
|
||||
service_type: "grafana",
|
||||
name: "Main Grafana",
|
||||
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useGrafanaStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "11.0.0",
|
||||
service_id: "graf-1",
|
||||
name: "Main Grafana",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useMonitoringMachines: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "storage",
|
||||
mode: "ssh",
|
||||
host: "10.0.0.5",
|
||||
enabled: true,
|
||||
services: [],
|
||||
port: 22,
|
||||
username: "admin",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LinksTab", () => {
|
||||
it("renders the Grafana version and machine dashboard links", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open-in-grafana link buttons", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
const links = screen.getAllByText("Open in Grafana");
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MediaTab } from "../MediaTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", user_id: "u1" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({
|
||||
data: { exists: true, item_count: 42, updated_at_label: "today" },
|
||||
}),
|
||||
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
|
||||
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 30 },
|
||||
}),
|
||||
useLibraries: () => ({ data: [{ id: "lib1" }] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: () => [
|
||||
{
|
||||
search: "",
|
||||
types: "Movie,Episode",
|
||||
hdrFilter: "All",
|
||||
sortKey: "title",
|
||||
sortOrder: "Ascending",
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
columnVisibility: {},
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MediaTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaTab", () => {
|
||||
it("renders index status and build controls with instance-scoped data", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/42 items/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Build index/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders library counts", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 series/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the filter card with search input", () => {
|
||||
renderTab();
|
||||
expect(screen.getByLabelText("Search")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessagingTab } from "../MessagingTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "auth-1",
|
||||
service_type: "authentik",
|
||||
name: "Main Authentik",
|
||||
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
|
||||
secrets_set: { api_token: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
useAuthentikUsers: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
pk: 1,
|
||||
username: "alice",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
})),
|
||||
useSendAuthentikMessage: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
data: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("MessagingTab", () => {
|
||||
it("renders the compose form (subject, body, send)", () => {
|
||||
render(<MessagingTab instance={instance} />);
|
||||
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Send message" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recipient toggle buttons from the directory", () => {
|
||||
render(<MessagingTab instance={instance} />);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricsTab } from "../MetricsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "prom-1",
|
||||
service_type: "prometheus",
|
||||
name: "Main Prometheus",
|
||||
config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
usePrometheusStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "2.52.0",
|
||||
service_id: "prom-1",
|
||||
name: "Main Prometheus",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
usePrometheusTargets: () => ({
|
||||
data: [
|
||||
{
|
||||
targets: ["10.0.0.5:9100"],
|
||||
labels: { instance: "storage", job: "node_exporter" },
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MetricsTab", () => {
|
||||
it("renders the Prometheus version and target list", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target count in the heading", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RequestsTab } from "../RequestsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
function makeInstance(config: Record<string, unknown>): ServiceInstance {
|
||||
return {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config,
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RequestsTab", () => {
|
||||
it("shows empty-state CTA when Jellyseerr is not configured", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
user_id: "u1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the configured Jellyseerr URL when both fields are set", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
jellyseerr_api_key: "secret-key",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("https://requests.example.com"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty-state when only URL is set (missing api_key)", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { UsersTab } from "../UsersTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "auth-1",
|
||||
service_type: "authentik",
|
||||
name: "Main Authentik",
|
||||
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
|
||||
secrets_set: { api_token: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
useAuthentikUsers: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
pk: 1,
|
||||
username: "alice",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
pk: 2,
|
||||
username: "bob",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
is_active: false,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
},
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("UsersTab", () => {
|
||||
it("renders the directory table with users", () => {
|
||||
render(<UsersTab instance={instance} />);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search input and pagination", () => {
|
||||
render(<UsersTab instance={instance} />);
|
||||
expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 users/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Previous")).toBeInTheDocument();
|
||||
expect(screen.getByText("Next")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user