deckhearth/test/components/Layout.test.js

82 lines
3.1 KiB
JavaScript
Raw Normal View History

feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user) Closes the source-side half of P0 #7 from .convoys/ship-readiness.md. The page-side sweep (Brief 2) follows in a separate commit. components/Layout.js: - Default user prop is now null (was hardcoded to { email: 'me@randallstillwell.com', role: 'user' }) - UserProfileDropdown renders a "Sign in" link to /login when user === null instead of the maintainer's email + auth-only menu items (Decision A1) - All user.* accesses guarded with optional chaining or null checks - useState hook stays above the new null-user early return to satisfy rules-of-hooks (boot-the-brief caught this on the first try; see AGENTS.md Gotcha #11.5) test/components/Layout.test.js (new): - First jsdom test in the repo (Decision D2) - 5 regression-lock assertions: no maintainer email ever rendered (prop omitted, prop=null), Sign-in link exists with href=/login, supplied email renders when prop is set, no "Guest" placeholder (locks A1 copy choice) - Mocks next/link, next/router (prefetch, replace, events, query), and theme-context.useTheme for jsdom safety under Next 16 package.json + package-lock.json: - Add jsdom@^29 and @testing-library/react@^16 to devDependencies - @testing-library/dom@^10 added explicitly (peer auto-install skipped it under npm 11; brief anticipated this fallback) vitest.config.js (deviation from brief — see PR description): - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can parse JSX in .js files. Required to import any React component written in the repo's Next.js pages-router .js convention (AGENTS.md Gotcha #9). The brief said "no change" to this file, but JSX-in-.js parsing is a hard prerequisite for the new test to import components/Layout.js — the alternatives (rename test to .test.jsx; rewrite test in React.createElement) either break the test glob or still hit the same Layout.js parse failure. Other tests are unaffected (they import non-JSX modules). Smoke output: see PR description. addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker) Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 10:14:13 -04:00
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen } from '@testing-library/react';
// Mock next/router so useRouter() does not crash without a RouterContext.
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
// does not throw when <Link> mounts.
vi.mock('next/router', () => ({
useRouter: () => ({
pathname: '/',
asPath: '/',
query: {},
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn().mockResolvedValue(undefined),
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}),
}));
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
// mount via the router; bypassing it removes a class of jsdom flake without
// changing the rendered DOM that the assertions inspect.
vi.mock('next/link', () => ({
__esModule: true,
default: ({ href, children, ...rest }) => {
return (
<a href={typeof href === 'string' ? href : ''} {...rest}>
{children}
</a>
);
},
}));
// Mock the theme context so useTheme() does not require a ThemeProvider.
vi.mock('../../lib/theme-context', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
}));
import Layout from '../../components/Layout';
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
afterEach(() => cleanup());
it('does NOT render the maintainer email when no user prop is passed', () => {
const { container } = render(<Layout>page body</Layout>);
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('does NOT render the maintainer email when user is null', () => {
const { container } = render(<Layout user={null}>page body</Layout>);
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('renders a Sign-in link to /login when user is null', () => {
render(<Layout user={null}>page body</Layout>);
const links = screen.getAllByRole('link', { name: /sign in/i });
expect(links.length).toBeGreaterThanOrEqual(1);
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
// so we expect TWO Sign-in links (one per copy).
for (const link of links) {
expect(link.getAttribute('href')).toBe('/login');
}
});
it('renders the supplied user email when user is an object', () => {
const { container } = render(
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
);
expect(container.textContent).toContain('foo@bar.com');
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('does NOT render a "Guest" placeholder when logged out', () => {
// Decision A says the logged-out copy is "Sign in", not "Guest".
// This test prevents a future revert that ships "Guest" as the default
// (which would still hide the maintainer email but skip the CTA).
const { container } = render(<Layout user={null}>page body</Layout>);
expect(container.textContent).not.toContain('Guest');
});
});