Files
silo-server/web/src/components/AppSidebar.test.tsx
T
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00

185 lines
5.9 KiB
TypeScript

// @vitest-environment node
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar from "./AppSidebar";
import { getProfileMenuSide, isSidebarExpanded } from "./AppSidebar.logic";
const mockLogout = vi.fn();
const mockClearProfile = vi.fn();
const mockTogglePin = vi.fn();
vi.mock("@/hooks/useAuth", () => {
const useAuth = () => ({
user: { id: 1, username: "alex", role: "admin" },
logout: mockLogout,
clearProfile: mockClearProfile,
});
return { useAuth, useOptionalAuth: useAuth };
});
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => ({
profile: { name: "Alex" },
}),
}));
vi.mock("@/hooks/queries/libraries", () => ({
useUserLibraries: () => ({
data: [{ id: 7, name: "Movies", type: "movies" }],
}),
}));
vi.mock("@/hooks/queries/sidebarPins", () => ({
useSidebarPins: () => ({
pins: {
"7": [{ type: "section", id: "featured", label: "Featured" }],
},
}),
useToggleSidebarPin: () => ({
togglePin: mockTogglePin,
}),
}));
vi.mock("@/hooks/queries/pluginSettings", () => ({
usePluginSettingsList: () => ({
data: { installations: [] },
}),
}));
vi.mock("@/hooks/queries/useRequests", () => ({
useRequestFeatureStatus: () => ({
data: { requests_enabled: false },
}),
}));
vi.mock("@/hooks/queries/notifications", () => ({
useUnreadNotificationCount: () => ({ data: 0 }),
}));
vi.mock("@/hooks/queries/notificationWebhooks", () => ({
useNotificationCapability: () => ({ data: { in_app: { enabled: true } }, isError: false }),
}));
vi.mock("@/hooks/useViewTransition", () => ({
useViewTransitionNavigate: () => vi.fn(),
}));
vi.mock("@/hooks/useServerBranding", () => ({
useServerBranding: () => ({
serverName: "Silo",
}),
}));
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "dark",
setTheme: vi.fn(),
previewTheme: vi.fn(),
resetPreviewTheme: vi.fn(),
}),
}));
vi.mock("@/components/ThemeSwitcher", () => ({
default: () => <div>Theme switcher</div>,
}));
vi.mock("@/components/ui/avatar", () => ({
Avatar: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AvatarFallback: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
}));
function renderSidebar(entry: string, { collapsed = false }: { collapsed?: boolean } = {}) {
return renderToStaticMarkup(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="*" element={<AppSidebar collapsed={collapsed} />} />
</Routes>
</MemoryRouter>,
);
}
describe("AppSidebar", () => {
beforeEach(() => {
mockLogout.mockReset();
mockClearProfile.mockReset();
mockTogglePin.mockReset();
});
it("uses the cinema highlight text color for active catalog source links", () => {
const markup = renderSidebar("/catalog?source=query&q=heat");
expect(markup).toContain("text-sidebar-accent-foreground bg-sidebar-accent");
expect(markup).not.toContain("text-sidebar-primary-foreground bg-sidebar-accent");
});
it("renders the Silo brand mark instead of the old play glyph", () => {
const markup = renderSidebar("/");
expect(markup).toContain('src="/silo-wordmark-sidebar.png"');
expect(markup).toContain('alt="Silo"');
expect(markup).not.toContain("▶");
});
it("uses the icon-only mark when the sidebar is collapsed", () => {
const markup = renderSidebar("/", { collapsed: true });
expect(markup).toContain('src="/silo-icon-1024.png"');
expect(markup).not.toContain('src="/silo-wordmark-sidebar.png"');
expect(markup).toContain("sidebar-logo flex items-center py-6 justify-center px-2");
});
it("uses the cinema highlight text color for active pinned catalog destinations", () => {
const markup = renderSidebar(
"/catalog?source=section&scope=library&library_id=7&section_id=featured&title=Featured",
);
expect(markup).toContain("text-sidebar-accent-foreground bg-sidebar-accent");
expect(markup).not.toContain("text-sidebar-primary-foreground bg-sidebar-accent");
});
it("preserves the current library query when linking to the active library", () => {
const markup = renderSidebar("/library/7?tab=library&sort=year&order=desc");
expect(markup).toContain('href="/library/7?tab=library&amp;sort=year&amp;order=desc"');
});
it("keeps collapsed navigation rows left-anchored instead of centering icons", () => {
const markup = renderSidebar("/item/42", { collapsed: true });
expect(markup).toContain('href="/"');
expect(markup).toContain('class="relative flex items-center gap-2.5 rounded-xl px-3 py-3');
});
it("preserves section header slots when collapsed so nav groups do not shift upward", () => {
const markup = renderSidebar("/item/42", { collapsed: true });
expect(markup).toContain("Libraries");
expect(markup).toContain("Discover");
expect(markup).toContain("Your Stuff");
});
it("keeps the collapsed sidebar expanded while the profile menu is open without changing menu side", () => {
expect(isSidebarExpanded(true, false, true)).toBe(true);
expect(getProfileMenuSide(true)).toBe("right");
});
it("centers the profile trigger when the sidebar is collapsed", () => {
const markup = renderSidebar("/item/42", { collapsed: true });
expect(markup).toContain("mx-auto h-10 w-10 justify-center px-0");
});
});