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>
185 lines
5.9 KiB
TypeScript
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§ion_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&sort=year&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");
|
|
});
|
|
});
|